diff --git a/Dockerfile b/Dockerfile index 647dce3..7a67dc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -196,6 +196,9 @@ RUN pip install pyyaml user-agents cryptography # OpenAPI CLI generator RUN pip install openapi-python-client +# QR code generator +RUN pip install pyqrcode pypng + # Automatic type conversion pydantic -> typescript RUN pip install pydantic typing-inspect jinja2 RUN apt-get update \ diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index b41cedc..28d93e8 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,42 +1,24 @@ { "env": { "browser": true, - "jest": true + "es2021": true, + "node": true }, "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", - "plugin:react-hooks/recommended", "plugin:prettier/recommended" ], "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2021, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - }, - "plugins": [ - "@typescript-eslint", - "react", - "react-hooks", - "prettier" - ], + "plugins": ["@typescript-eslint", "react", "react-hooks"], "rules": { - "react/prop-types": "off", - "@typescript-eslint/explicit-function-return-type": "warn", - "no-unused-vars": "off", - "prettier/prettier": "error", - "@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] + "react/react-in-jsx-scope": "off", + "prettier/prettier": ["error", { "arrowParens": "avoid" }] }, "settings": { "react": { "version": "detect" - }, - "import/resolver": { - "typescript": {} } } } diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 59ea9c5..6df9190 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,2 +1,6 @@ src/types/* +node_modules +build +dist +coverage diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 81dcf5c..b6ce9fd 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1,10 +1,9 @@ { "semi": true, - "trailingComma": "es5", "singleQuote": true, "printWidth": 100, "tabWidth": 2, - "useTabs": false, + "trailingComma": "es5", "bracketSpacing": true, "arrowParens": "avoid" } diff --git a/frontend/src/components/ui/JobViewer.tsx b/frontend/src/components/ui/JobViewer.tsx deleted file mode 100644 index 6724db9..0000000 --- a/frontend/src/components/ui/JobViewer.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import React, { JSX, useEffect, useState } from 'react'; -import { - Box, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, - FormControl, - Select, - MenuItem, - InputLabel, - Chip, - IconButton, - Dialog, - AppBar, - Toolbar, - useMediaQuery, - useTheme, - Slide, -} from '@mui/material'; -import { - KeyboardArrowUp as ArrowUpIcon, - KeyboardArrowDown as ArrowDownIcon, - Business as BusinessIcon, - Work as WorkIcon, - Schedule as ScheduleIcon, - ArrowBack as ArrowBackIcon, -} from '@mui/icons-material'; -import { TransitionProps } from '@mui/material/transitions'; -import { JobInfo } from 'components/ui/JobInfo'; -import { Job } from 'types/types'; -import { useAuth } from 'hooks/AuthContext'; -import { useAppState, useSelectedJob } from 'hooks/GlobalContext'; -import { useNavigate, useParams } from 'react-router-dom'; - -type SortField = 'updatedAt' | 'createdAt' | 'company' | 'title'; -type SortOrder = 'asc' | 'desc'; - -interface JobViewerProps { - onSelect?: (job: Job) => void; -} - -const Transition = React.forwardRef(function Transition( - props: TransitionProps & { - children: React.ReactElement; - }, - ref: React.Ref -) { - return ; -}); - -const JobViewer: React.FC = ({ onSelect }) => { - const navigate = useNavigate(); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - const isSmall = useMediaQuery(theme.breakpoints.down('sm')); - - const { apiClient } = useAuth(); - const { selectedJob, setSelectedJob } = useSelectedJob(); - const { setSnack } = useAppState(); - const [jobs, setJobs] = useState([]); - const [loading, setLoading] = useState(false); - const [sortField, setSortField] = useState('updatedAt'); - const [sortOrder, setSortOrder] = useState('desc'); - const [mobileDialogOpen, setMobileDialogOpen] = useState(false); - const { jobId } = useParams<{ jobId?: string }>(); - - useEffect(() => { - if (loading || jobs.length !== 0) return; // Prevent multiple calls - const getJobs = async (): Promise => { - try { - const results = await apiClient.getJobs(); - const jobsData: Job[] = results.data || []; - setJobs(jobsData); - - if (jobId) { - const job = jobsData.find(j => j.id === jobId); - if (job) { - setSelectedJob(job); - onSelect?.(job); - setMobileDialogOpen(true); - return; - } - } - - // Auto-select first job if none selected - if (jobsData.length > 0 && !selectedJob) { - const firstJob = sortJobs(jobsData, sortField, sortOrder)[0]; - setSelectedJob(firstJob); - onSelect?.(firstJob); - } - } catch (err) { - setSnack('Failed to load jobs: ' + err); - } finally { - setLoading(false); - } - }; - - setLoading(true); - getJobs(); - }, [ - apiClient, - setSnack, - loading, - jobId, - selectedJob, - onSelect, - sortField, - sortOrder, - setSelectedJob, - jobs.length, - ]); - - const sortJobs = (jobsList: Job[], field: SortField, order: SortOrder): Job[] => { - return [...jobsList].sort((a, b) => { - let aValue: number | string; - let bValue: number | string; - - switch (field) { - case 'updatedAt': - aValue = a.updatedAt?.getTime() || 0; - bValue = b.updatedAt?.getTime() || 0; - break; - case 'createdAt': - aValue = a.createdAt?.getTime() || 0; - bValue = b.createdAt?.getTime() || 0; - break; - case 'company': - aValue = a.company?.toLowerCase() || ''; - bValue = b.company?.toLowerCase() || ''; - break; - case 'title': - aValue = a.title?.toLowerCase() || ''; - bValue = b.title?.toLowerCase() || ''; - break; - default: - return 0; - } - - if (aValue < bValue) return order === 'asc' ? -1 : 1; - if (aValue > bValue) return order === 'asc' ? 1 : -1; - return 0; - }); - }; - - const handleSort = (field: SortField): void => { - if (sortField === field) { - setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); - } else { - setSortField(field); - setSortOrder('desc'); - } - }; - - const handleJobSelect = (job: Job): void => { - setSelectedJob(job); - onSelect?.(job); - setMobileDialogOpen(true); - navigate(`/candidate/jobs/${job.id}`); - }; - - const handleMobileDialogClose = (): void => { - setMobileDialogOpen(false); - }; - - const sortedJobs = sortJobs(jobs, sortField, sortOrder); - - const formatDate = (date: Date | undefined): string => { - if (!date) return 'N/A'; - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - ...(isMobile ? {} : { year: 'numeric' }), - ...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }), - }).format(date); - }; - - const getSortIcon = (field: SortField): JSX.Element => { - if (sortField !== field) return <>; - return sortOrder === 'asc' ? ( - - ) : ( - - ); - }; - - const JobList = (): JSX.Element => ( - - - - Jobs ({jobs.length}) - - - - Sort by - - - - - - - - - handleSort('company')} - > - - - - {isSmall ? 'Co.' : isMobile ? 'Company' : 'Company'} - - {getSortIcon('company')} - - - handleSort('title')} - > - - - - Title - - {getSortIcon('title')} - - - {!isMobile && ( - handleSort('updatedAt')} - > - - - - Updated - - {getSortIcon('updatedAt')} - - - )} - - - {isMobile ? 'Status' : 'Status'} - - - - - - {sortedJobs.map(job => ( - { - handleJobSelect(job); - }} - sx={{ - cursor: 'pointer', - height: isMobile ? 48 : 'auto', - '&.Mui-selected': { - backgroundColor: 'action.selected', - }, - '&:hover': { - backgroundColor: 'action.hover', - }, - }} - > - - - {job.company || 'N/A'} - - {!isMobile && job.details?.location && ( - - {job.details.location.city},{' '} - {job.details.location.state || job.details.location.country} - - )} - - - - {job.title || 'N/A'} - - {!isMobile && job.details?.employmentType && ( - - )} - - {!isMobile && ( - - - {formatDate(job.updatedAt)} - - {job.createdAt && ( - - Created: {formatDate(job.createdAt)} - - )} - - )} - - - - - ))} - -
-
-
- ); - - const JobDetails = ({ inDialog = false }: { inDialog?: boolean }): JSX.Element => ( - - {selectedJob ? ( - - ) : ( - - Select a job to view details - - )} - - ); - - return ( - - - - - - - - - - - - {selectedJob?.title} - - - {selectedJob?.company} - - - - - - - - ); -}; - -export { JobViewer }; diff --git a/frontend/src/components/ui/JobsTable.tsx b/frontend/src/components/ui/JobsTable.tsx index 8b5e1fa..c86b47b 100644 --- a/frontend/src/components/ui/JobsTable.tsx +++ b/frontend/src/components/ui/JobsTable.tsx @@ -255,7 +255,13 @@ const JobsTable: React.FC = ({ role="checkbox" aria-checked={isItemSelected} selected={isItemSelected} - sx={{ '&:last-child td, &:last-child th': { border: 0 } }} + onClick={(): void => { + !selectable && handleSelectJob(job.id || ''); + }} + sx={{ + '&:last-child td, &:last-child th': { border: 0 }, + cursor: selectable ? 'default' : 'pointer', + }} > {selectable && ( diff --git a/frontend/src/components/ui/JobsView.tsx b/frontend/src/components/ui/JobsView.tsx new file mode 100644 index 0000000..b26a2d9 --- /dev/null +++ b/frontend/src/components/ui/JobsView.tsx @@ -0,0 +1,659 @@ +import React from 'react'; +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + FormControl, + Select, + MenuItem, + InputLabel, + Chip, + IconButton, + Dialog, + AppBar, + Toolbar, + useMediaQuery, + useTheme, + Slide, + Checkbox, + TablePagination, + TextField, + InputAdornment, + CircularProgress, + Alert, + Tooltip, + Grid, +} from '@mui/material'; +import { + KeyboardArrowUp as ArrowUpIcon, + KeyboardArrowDown as ArrowDownIcon, + Business as BusinessIcon, + Work as WorkIcon, + Schedule as ScheduleIcon, + ArrowBack as ArrowBackIcon, + Search as SearchIcon, + Visibility as VisibilityIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Close as CloseIcon, +} from '@mui/icons-material'; +import { TransitionProps } from '@mui/material/transitions'; +import * as Types from 'types/types'; // Adjust the import path as necessary +import { useAuth } from 'hooks/AuthContext'; +import { StyledMarkdown } from 'components/StyledMarkdown'; +import { Scrollable } from 'components/Scrollable'; + +// async searchJobs(query: string): Promise { +// const results = await this.getJobs(); +// const filtered = results.data.filter(job => +// job.title.toLowerCase().includes(query.toLowerCase()) || +// job.description.toLowerCase().includes(query.toLowerCase()) || +// job.company?.toLowerCase().includes(query.toLowerCase()) +// ); +// return { +// data: filtered, +// totalPages: 1, +// totalItems: filtered.length, +// }; +// } + +type SortField = 'updatedAt' | 'createdAt' | 'company' | 'title'; +type SortOrder = 'asc' | 'desc'; + +interface JobsViewProps { + onJobSelect?: (selectedJobs: Types.Job[]) => void; + onJobView?: (job: Types.Job) => void; + onJobEdit?: (job: Types.Job) => void; + onJobDelete?: (job: Types.Job) => void; + selectable?: boolean; + showActions?: boolean; + showDetailsPanel?: boolean; + variant?: 'table' | 'list' | 'responsive'; +} + +const Transition = React.forwardRef(function Transition( + props: TransitionProps & { + children: React.ReactElement; + }, + ref: React.Ref +) { + return ; +}); + +const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?: boolean }> = ({ + job, + onClose, + inDialog = false, +}) => ( + + + + {job.title} + + {onClose && !inDialog && ( + + + + )} + + + + {job.company} + + + + + {job.details?.employmentType && ( + + )} + + + {job.details?.location && ( + + 📍 {job.details.location.city}, {job.details.location.state || job.details.location.country} + + )} + + + + {job.requirements && + job.requirements.technicalSkills && + job.requirements.technicalSkills.required && + job.requirements.technicalSkills.required.length > 0 && ( + + + Required Skills + + + {job.requirements.technicalSkills.required.map(skill => ( + + ))} + + + )} + + + + Posted: {job.createdAt?.toLocaleDateString()} + + + Updated: {job.updatedAt?.toLocaleDateString()} + + {/* {job.views && ( + + Views: {job.views} + + )} */} + + +); + +const JobsView: React.FC = ({ + onJobSelect, + onJobView, + onJobEdit, + onJobDelete, + selectable = true, + showActions = true, + showDetailsPanel = true, +}) => { + const theme = useTheme(); + const { apiClient } = useAuth(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + + const [jobs, setJobs] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [page, setPage] = React.useState(0); + const [limit, setLimit] = React.useState(25); + const [total, setTotal] = React.useState(0); + const [searchQuery, setSearchQuery] = React.useState(''); + const [searchTimeout, setSearchTimeout] = React.useState(null); + const [selectedJobs, setSelectedJobs] = React.useState>(new Set()); + const [selectedJob, setSelectedJob] = React.useState(null); + const [sortField, setSortField] = React.useState('updatedAt'); + const [sortOrder, setSortOrder] = React.useState('desc'); + const [mobileDialogOpen, setMobileDialogOpen] = React.useState(false); + const [detailsPanelOpen, setDetailsPanelOpen] = React.useState(showDetailsPanel); + + const fetchJobs = React.useCallback( + async (pageNum = 0, searchTerm = '') => { + try { + setLoading(true); + setError(null); + const paginationRequest: Partial = { + page: pageNum + 1, + limit: limit, + sortBy: sortField, + sortOrder: sortOrder, + }; + + let paginationResponse: Types.PaginatedResponse; + if (searchTerm.trim()) { + paginationResponse = await apiClient.searchJobs(searchTerm); + } else { + paginationResponse = await apiClient.getJobs(paginationRequest); + } + + const sortedJobs = sortJobs(paginationResponse.data, sortField, sortOrder); + setJobs(sortedJobs); + setTotal(paginationResponse.total); + + if (sortedJobs.length > 0 && !selectedJob && detailsPanelOpen) { + setSelectedJob(sortedJobs[0]); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred while fetching jobs'); + setJobs([]); + setTotal(0); + } finally { + setLoading(false); + } + }, + [limit, sortField, sortOrder, selectedJob, detailsPanelOpen, apiClient] + ); + + React.useEffect(() => { + fetchJobs(0, searchQuery); + }, [fetchJobs, searchQuery]); + + const sortJobs = (jobsList: Types.Job[], field: SortField, order: SortOrder): Types.Job[] => { + return [...jobsList].sort((a, b) => { + let aValue: number | string; + let bValue: number | string; + + switch (field) { + case 'updatedAt': + aValue = a.updatedAt?.getTime() || 0; + bValue = b.updatedAt?.getTime() || 0; + break; + case 'createdAt': + aValue = a.createdAt?.getTime() || 0; + bValue = b.createdAt?.getTime() || 0; + break; + case 'company': + aValue = a.company?.toLowerCase() || ''; + bValue = b.company?.toLowerCase() || ''; + break; + case 'title': + aValue = a.title?.toLowerCase() || ''; + bValue = b.title?.toLowerCase() || ''; + break; + default: + return 0; + } + + if (aValue < bValue) return order === 'asc' ? -1 : 1; + if (aValue > bValue) return order === 'asc' ? 1 : -1; + return 0; + }); + }; + + const handleSearchChange = (event: React.ChangeEvent): void => { + const value = event.target.value; + setSearchQuery(value); + + if (searchTimeout) { + clearTimeout(searchTimeout); + } + + const timeout = setTimeout(() => { + setPage(0); + fetchJobs(0, value); + }, 500); + + setSearchTimeout(timeout); + }; + + const handlePageChange = (event: unknown, newPage: number): void => { + setPage(newPage); + fetchJobs(newPage, searchQuery); + }; + + const handleRowsPerPageChange = (event: React.ChangeEvent): void => { + const newLimit = parseInt(event.target.value, 10); + setLimit(newLimit); + setPage(0); + fetchJobs(0, searchQuery); + }; + + const handleSelectAll = (event: React.ChangeEvent): void => { + if (event.target.checked) { + const newSelected = new Set(jobs.map(job => job.id || '')); + setSelectedJobs(newSelected); + onJobSelect?.(jobs); + } else { + setSelectedJobs(new Set()); + onJobSelect?.([]); + } + }; + + const handleSelectJob = (jobId: string): void => { + const newSelected = new Set(selectedJobs); + if (newSelected.has(jobId)) { + newSelected.delete(jobId); + } else { + newSelected.add(jobId); + } + setSelectedJobs(newSelected); + + const selectedJobsList = jobs.filter(job => newSelected.has(job.id || '')); + onJobSelect?.(selectedJobsList); + }; + + const handleSort = (field: SortField): void => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('desc'); + } + }; + + const handleJobRowClick = (job: Types.Job): void => { + if (isMobile) { + setSelectedJob(job); + setMobileDialogOpen(true); + } else if (detailsPanelOpen) { + setSelectedJob(job); + setDetailsPanelOpen(true); + } + onJobView?.(job); + }; + + const getOwnerName = (owner?: Types.BaseUser): string => { + if (!owner) return 'Unknown'; + return `${owner.firstName || ''} ${owner.lastName || ''}`.trim() || owner.email || 'Unknown'; + }; + + const truncateDescription = (description: string, maxLength = 100): string => { + if (description.length <= maxLength) return description; + return description.substring(0, maxLength) + '...'; + }; + + const formatDate = (date: Date | undefined): string => { + if (!date) return 'N/A'; + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + ...(isMobile ? {} : { year: 'numeric' }), + ...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }), + }).format(date); + }; + + const getSortIcon = (field: SortField): React.ReactElement => { + if (sortField !== field) return <>; + return sortOrder === 'asc' ? ( + + ) : ( + + ); + }; + + const isSelected = (jobId: string): boolean => selectedJobs.has(jobId); + const numSelected = selectedJobs.size; + const rowCount = jobs.length; + + if (error) { + return ( + + {error} + + ); + } + + const tableContent = ( + <> + + + + + Jobs ({total}) + + + + + + + + ), + }} + /> + + Sort + + + + + + + + + + + + {selectable && ( + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={handleSelectAll} + /> + + )} + handleSort('company')}> + + + Company {getSortIcon('company')} + + + handleSort('title')}> + + + Title {getSortIcon('title')} + + + Description + Owner + handleSort('updatedAt')}> + + + Updated {getSortIcon('updatedAt')} + + + Status + {showActions && Actions} + + + + {loading ? ( + + + + + + ) : jobs.length === 0 ? ( + + + + No jobs found + + + + ) : ( + jobs.map(job => { + const isItemSelected = isSelected(job.id || ''); + return ( + handleJobRowClick(job)} + sx={{ cursor: 'pointer' }} + > + {selectable && ( + e.stopPropagation()}> + handleSelectJob(job.id || '')} + /> + + )} + + + {job.company} + + {job.details?.location && ( + + {job.details.location.city}, {job.details.location.state} + + )} + + + + {job.title} + + {job.details?.employmentType && ( + + )} + + + + {truncateDescription(job.description)} + + + + {getOwnerName(job.owner)} + + + {formatDate(job.updatedAt)} + + + + + {showActions && ( + e.stopPropagation()}> + + {onJobView && ( + + onJobView(job)}> + + + + )} + {onJobEdit && ( + + onJobEdit(job)}> + + + + )} + {onJobDelete && ( + + onJobDelete(job)}> + + + + )} + + + )} + + ); + }) + )} + +
+
+ + + + ); + + return ( + + + {tableContent} + + + {detailsPanelOpen && !isMobile && ( + + + {selectedJob ? ( + setSelectedJob(null)} /> + ) : ( + + Select a job to view details + + )} + + + )} + + setMobileDialogOpen(false)} + TransitionComponent={Transition} + > + + + setMobileDialogOpen(false)} + > + + + + + {selectedJob?.title} + + + {selectedJob?.company} + + + + + {selectedJob && } + + + ); +}; + +export { JobsView }; diff --git a/frontend/src/components/ui/ResumeInfo.css b/frontend/src/components/ui/ResumeInfo.css index 4426d77..23fcdfb 100644 --- a/frontend/src/components/ui/ResumeInfo.css +++ b/frontend/src/components/ui/ResumeInfo.css @@ -5,6 +5,8 @@ } .a4-document { + /* display: flex; */ + /* position: relative; */ /* A4 dimensions: 210mm x 297mm */ width: 210mm; min-height: 297mm; diff --git a/frontend/src/components/ui/ResumeInfo.tsx b/frontend/src/components/ui/ResumeInfo.tsx index 2f32948..1e7f2c4 100644 --- a/frontend/src/components/ui/ResumeInfo.tsx +++ b/frontend/src/components/ui/ResumeInfo.tsx @@ -594,8 +594,30 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { }} content={editContent} /> + + See my full Backstory at... + + {activeResume.candidate?.username + ? `https://backstory.ketrenos.com/u/${activeResume.candidate?.username}` + : 'backstory'} + -   +   )} diff --git a/frontend/src/components/ui/ResumeViewer.tsx b/frontend/src/components/ui/ResumeViewer.tsx index 9fce424..91494c3 100644 --- a/frontend/src/components/ui/ResumeViewer.tsx +++ b/frontend/src/components/ui/ResumeViewer.tsx @@ -76,6 +76,30 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI const { resumeId } = useParams<{ resumeId?: string }>(); useEffect(() => { + if (resumeId) { + const resume = resumes.find(r => r.id === resumeId); + if (resume) { + setSelectedResume(resume); + onSelect?.(resume); + return; + } + } + }, [resumes, resumeId, setSelectedResume, onSelect]); + + useEffect(() => { + // Auto-select first resume if none selected + if (filteredResumes.length > 0 && !selectedResume) { + const firstResume = sortResumes(filteredResumes, sortField, sortOrder)[0]; + setSelectedResume(firstResume); + onSelect?.(firstResume); + } + }, [selectedResume, setSelectedResume, onSelect, filteredResumes, sortField, sortOrder]); + + useEffect(() => { + if (resumes.length > 0) { + return; // Avoid re-fetching if resumes are already loaded + } + const getResumes = async (): Promise => { try { let results; @@ -91,22 +115,6 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI const resumesData: Resume[] = results.resumes || []; setResumes(resumesData); setFilteredResumes(resumesData); - - if (resumeId) { - const resume = resumesData.find(r => r.id === resumeId); - if (resume) { - setSelectedResume(resume); - onSelect?.(resume); - return; - } - } - - // Auto-select first resume if none selected - if (resumesData.length > 0 && !selectedResume) { - const firstResume = sortResumes(resumesData, sortField, sortOrder)[0]; - setSelectedResume(firstResume); - onSelect?.(firstResume); - } } catch (err) { console.error('Failed to load resumes:', err); setSnack('Failed to load resumes: ' + err, 'error'); @@ -125,6 +133,7 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI onSelect, sortField, sortOrder, + resumes.length, ]); // Filter resumes based on search query diff --git a/frontend/src/config/navigationConfig.tsx b/frontend/src/config/navigationConfig.tsx index 67f6efa..f0f7465 100644 --- a/frontend/src/config/navigationConfig.tsx +++ b/frontend/src/config/navigationConfig.tsx @@ -27,7 +27,7 @@ import { VectorVisualizer } from 'components/VectorVisualizer'; import { DocumentManager } from 'components/DocumentManager'; import { useAuth } from 'hooks/AuthContext'; import { useNavigate } from 'react-router-dom'; -import { JobViewer } from 'components/ui/JobViewer'; +import { JobsView } from 'components/ui/JobsView'; import { ResumeViewer } from 'components/ui/ResumeViewer'; import { JobsTable } from 'components/ui/JobsTable'; @@ -56,6 +56,7 @@ export const navigationConfig: NavigationConfig = { id: 'job-analysis', label: 'Job Analysis', path: '/job-analysis', + variant: 'fullWidth', icon: , component: , userTypes: ['guest', 'candidate', 'employer'], @@ -127,7 +128,8 @@ export const navigationConfig: NavigationConfig = { label: 'Jobs', path: '/candidate/jobs/:jobId?', icon: , - component: , + component: , + variant: 'fullWidth', userTypes: ['candidate', 'guest', 'employer'], showInNavigation: false, showInUserMenu: true, diff --git a/frontend/src/pages/JobAnalysisPage.tsx b/frontend/src/pages/JobAnalysisPage.tsx index 9424281..f644b50 100644 --- a/frontend/src/pages/JobAnalysisPage.tsx +++ b/frontend/src/pages/JobAnalysisPage.tsx @@ -30,7 +30,7 @@ import { JobCreator } from 'components/JobCreator'; import { LoginRestricted } from 'components/ui/LoginRestricted'; import { ResumeGenerator } from 'components/ResumeGenerator'; import { JobInfo } from 'components/ui/JobInfo'; -import { JobsTable } from 'components/ui/JobsTable'; +import { JobsView } from 'components/ui/JobsView'; function WorkAddIcon(): JSX.Element { return ( @@ -246,7 +246,7 @@ const JobAnalysisPage: React.FC = (_props: BackstoryPageProp - {jobTab === 'select' && } + {jobTab === 'select' && } {jobTab === 'create' && user && ( { diff --git a/src/backend/routes/auth.py b/src/backend/routes/auth.py index 95fbe9d..c0143b0 100644 --- a/src/backend/routes/auth.py +++ b/src/backend/routes/auth.py @@ -41,6 +41,8 @@ from utils.auth_utils import ( SecurityConfig, validate_password_strength, ) +import defines +import pyqrcode # Create router for authentication endpoints router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -1155,10 +1157,24 @@ async def verify_email( await database.set_user(employer.email, user_auth_data) await database.set_user(username, user_auth_data) await database.set_user_by_id(employer.id, user_auth_data) - + else: + logger.error(f"❌ Unknown user type in verification data: {user_type}") + return JSONResponse( + status_code=400, + content=create_error_response("INVALID_USER_TYPE", "Unknown user type in verification data"), + ) # Mark as verified await database.mark_email_verified(request.token) - + + # Create user directory + dir_path = os.path.join(defines.user_dir, username) + if not os.path.exists(dir_path): + os.makedirs(dir_path, exist_ok=True) + qrobj = pyqrcode.create(f"https://backstory.ketrenos.com/u/{username}") + dir_path = os.path.join(dir_path, "qrcode.png") + with open(dir_path, "wb") as f: + qrobj.png(f, scale=2) + logger.info(f"✅ Email verified and account activated for: {verification_data['email']}") return create_success_response({ diff --git a/src/backend/routes/candidates.py b/src/backend/routes/candidates.py index f73264f..8d20792 100644 --- a/src/backend/routes/candidates.py +++ b/src/backend/routes/candidates.py @@ -712,6 +712,44 @@ async def get_candidate_profile_image( status_code=500, content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image") ) +@router.get("/qr-code/{candidate_id}") +async def get_candidate_qr_code( + candidate_id: str = Path(..., description="ID of the candidate"), + # current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database), +): + """Get profile image of a candidate by username""" + try: + all_candidates_data = await database.get_all_candidates() + candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] + + # Normalize username to lowercase for case-insensitive search + query_lower = candidate_id.lower() + + # Filter by search query + candidates_list = [c for c in candidates_list if query_lower == c.id.lower()] + + if not len(candidates_list): + return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Candidate not found")) + + candidate = Candidate.model_validate(candidates_list[0]) + file_path = os.path.join(defines.user_dir, candidate.username, "qrcode.png") + file_path = pathlib.Path(file_path) + if not file_path.exists(): + logger.error(f"❌ QR code not found on disk: {file_path}") + return JSONResponse( + status_code=404, content=create_error_response("FILE_NOT_FOUND", "QR code not found on disk") + ) + return FileResponse( + file_path, + media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot + filename="qrcode.png", + ) + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"❌ Get candidate QR code failed: {str(e)}") + return JSONResponse(status_code=500, content=create_error_response("FETCH_ERROR", "Failed to retrieve QR code")) + @router.get("/documents") async def get_candidate_documents(