Added qr code generation

This commit is contained in:
James Ketr 2025-06-27 09:55:58 -07:00
parent f3b9e0c2e7
commit 0c32e26955
14 changed files with 791 additions and 585 deletions

View File

@ -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 \

View File

@ -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": {}
}
}
}

View File

@ -1,2 +1,6 @@
src/types/*
node_modules
build
dist
coverage

View File

@ -1,10 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

@ -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<unknown>
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const JobViewer: React.FC<JobViewerProps> = ({ 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<Job[]>([]);
const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = useState<SortOrder>('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<void> => {
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' ? (
<ArrowUpIcon fontSize="small" />
) : (
<ArrowDownIcon fontSize="small" />
);
};
const JobList = (): JSX.Element => (
<Paper
elevation={isMobile ? 0 : 1}
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
boxShadow: 'none',
backgroundColor: 'transparent',
}}
>
<Box
sx={{
p: isMobile ? 0.5 : 1,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: isMobile ? 'background.paper' : 'inherit',
}}
>
<Typography
variant={isSmall ? 'subtitle2' : isMobile ? 'subtitle1' : 'h6'}
gutterBottom
sx={{ mb: isMobile ? 0.5 : 1, fontWeight: 600 }}
>
Jobs ({jobs.length})
</Typography>
<FormControl size="small" sx={{ minWidth: isSmall ? 120 : isMobile ? 150 : 200 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort by"
onChange={(e): void => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
setSortField(field);
setSortOrder(order);
}}
>
<MenuItem value="updatedAt-desc">Updated (Newest)</MenuItem>
<MenuItem value="updatedAt-asc">Updated (Oldest)</MenuItem>
<MenuItem value="createdAt-desc">Created (Newest)</MenuItem>
<MenuItem value="createdAt-asc">Created (Oldest)</MenuItem>
<MenuItem value="company-asc">Company (A-Z)</MenuItem>
<MenuItem value="company-desc">Company (Z-A)</MenuItem>
<MenuItem value="title-asc">Title (A-Z)</MenuItem>
<MenuItem value="title-desc">Title (Z-A)</MenuItem>
</Select>
</FormControl>
</Box>
<TableContainer
sx={{
flex: 1,
overflow: 'auto',
'& .MuiTable-root': {
tableLayout: isMobile ? 'fixed' : 'auto',
},
}}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '25%' : 'auto',
backgroundColor: 'background.paper',
}}
onClick={(): void => handleSort('company')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BusinessIcon fontSize={isMobile ? 'small' : 'medium'} />
<Typography variant="caption" fontWeight="bold" noWrap>
{isSmall ? 'Co.' : isMobile ? 'Company' : 'Company'}
</Typography>
{getSortIcon('company')}
</Box>
</TableCell>
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '45%' : 'auto',
backgroundColor: 'background.paper',
}}
onClick={(): void => handleSort('title')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WorkIcon fontSize={isMobile ? 'small' : 'medium'} />
<Typography variant="caption" fontWeight="bold" noWrap>
Title
</Typography>
{getSortIcon('title')}
</Box>
</TableCell>
{!isMobile && (
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: 0.5,
px: 1,
backgroundColor: 'background.paper',
}}
onClick={(): void => handleSort('updatedAt')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ScheduleIcon fontSize="medium" />
<Typography variant="caption" fontWeight="bold">
Updated
</Typography>
{getSortIcon('updatedAt')}
</Box>
</TableCell>
)}
<TableCell
sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '30%' : 'auto',
backgroundColor: 'background.paper',
}}
>
<Typography variant="caption" fontWeight="bold" noWrap>
{isMobile ? 'Status' : 'Status'}
</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedJobs.map(job => (
<TableRow
key={job.id}
hover
selected={selectedJob?.id === job.id}
onClick={(): void => {
handleJobSelect(job);
}}
sx={{
cursor: 'pointer',
height: isMobile ? 48 : 'auto',
'&.Mui-selected': {
backgroundColor: 'action.selected',
},
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<TableCell
sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden',
}}
>
<Typography
variant={isMobile ? 'caption' : 'body2'}
fontWeight="medium"
noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
>
{job.company || 'N/A'}
</Typography>
{!isMobile && job.details?.location && (
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: 'block', fontSize: '0.7rem' }}
>
{job.details.location.city},{' '}
{job.details.location.state || job.details.location.country}
</Typography>
)}
</TableCell>
<TableCell
sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden',
}}
>
<Typography
variant={isMobile ? 'caption' : 'body2'}
fontWeight="medium"
noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
>
{job.title || 'N/A'}
</Typography>
{!isMobile && job.details?.employmentType && (
<Chip
label={job.details.employmentType}
size="small"
variant="outlined"
sx={{
mt: 0.25,
fontSize: '0.6rem',
height: 16,
'& .MuiChip-label': { px: 0.5 },
}}
/>
)}
</TableCell>
{!isMobile && (
<TableCell sx={{ py: 0.5, px: 1 }}>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{formatDate(job.updatedAt)}
</Typography>
{job.createdAt && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }}
>
Created: {formatDate(job.createdAt)}
</Typography>
)}
</TableCell>
)}
<TableCell
sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden',
}}
>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
variant="outlined"
sx={{
fontSize: isMobile ? '0.65rem' : '0.7rem',
height: isMobile ? 20 : 22,
'& .MuiChip-label': {
px: isMobile ? 0.5 : 0.75,
py: 0,
},
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
const JobDetails = ({ inDialog = false }: { inDialog?: boolean }): JSX.Element => (
<Box
sx={{
flex: 1,
overflow: 'auto',
p: inDialog ? 1.5 : 0.75,
height: inDialog ? '100%' : 'auto',
}}
>
{selectedJob ? (
<JobInfo
job={selectedJob}
variant="all"
sx={{
border: 'none',
boxShadow: 'none',
backgroundColor: 'transparent',
'& .MuiTypography-h6': {
fontSize: inDialog ? '1.25rem' : '1.1rem',
},
}}
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'text.secondary',
textAlign: 'center',
p: 2,
}}
>
<Typography variant="body2">Select a job to view details</Typography>
</Box>
)}
</Box>
);
return (
<Box
sx={{
height: '100%',
p: 0.5,
backgroundColor: 'background.default',
}}
>
<JobList />
<Dialog
fullScreen
open={mobileDialogOpen}
onClose={handleMobileDialogClose}
TransitionComponent={Transition}
TransitionProps={{ timeout: 300 }}
>
<AppBar sx={{ position: 'relative', elevation: 1 }}>
<Toolbar variant="dense" sx={{ minHeight: 48 }}>
<IconButton
edge="start"
color="inherit"
onClick={handleMobileDialogClose}
aria-label="back"
size="small"
>
<ArrowBackIcon />
</IconButton>
<Box sx={{ ml: 1, flex: 1, minWidth: 0 }}>
<Typography variant="h6" component="div" noWrap sx={{ fontSize: '1rem' }}>
{selectedJob?.title}
</Typography>
<Typography
variant="caption"
component="div"
sx={{ color: 'rgba(255, 255, 255, 0.7)' }}
noWrap
>
{selectedJob?.company}
</Typography>
</Box>
</Toolbar>
</AppBar>
<JobDetails inDialog />
</Dialog>
</Box>
);
};
export { JobViewer };

View File

@ -255,7 +255,13 @@ const JobsTable: React.FC<JobsTableProps> = ({
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 && (
<TableCell padding="checkbox">

View File

@ -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<Types.PaginatedResponse> {
// 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<unknown>
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?: boolean }> = ({
job,
onClose,
inDialog = false,
}) => (
<Scrollable
sx={{
p: inDialog ? 2 : 1.5,
height: '100%',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Typography variant="h5" component="h1" gutterBottom>
{job.title}
</Typography>
{onClose && !inDialog && (
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
)}
</Box>
<Typography variant="h6" color="primary" gutterBottom>
{job.company}
</Typography>
<Box sx={{ mb: 2 }}>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
sx={{ mr: 1 }}
/>
{job.details?.employmentType && (
<Chip label={job.details.employmentType} variant="outlined" size="small" sx={{ mr: 1 }} />
)}
</Box>
{job.details?.location && (
<Typography variant="body2" color="text.secondary" gutterBottom>
📍 {job.details.location.city}, {job.details.location.state || job.details.location.country}
</Typography>
)}
<StyledMarkdown content={job.description} />
{job.requirements &&
job.requirements.technicalSkills &&
job.requirements.technicalSkills.required &&
job.requirements.technicalSkills.required.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Required Skills
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{job.requirements.technicalSkills.required.map(skill => (
<Chip key={skill} label={skill} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary">
Posted: {job.createdAt?.toLocaleDateString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Updated: {job.updatedAt?.toLocaleDateString()}
</Typography>
{/* {job.views && (
<Typography variant="body2" color="text.secondary">
Views: {job.views}
</Typography>
)} */}
</Box>
</Scrollable>
);
const JobsView: React.FC<JobsViewProps> = ({
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<Types.Job[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(null);
const [page, setPage] = React.useState<number>(0);
const [limit, setLimit] = React.useState<number>(25);
const [total, setTotal] = React.useState<number>(0);
const [searchQuery, setSearchQuery] = React.useState<string>('');
const [searchTimeout, setSearchTimeout] = React.useState<NodeJS.Timeout | null>(null);
const [selectedJobs, setSelectedJobs] = React.useState<Set<string>>(new Set());
const [selectedJob, setSelectedJob] = React.useState<Types.Job | null>(null);
const [sortField, setSortField] = React.useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = React.useState<SortOrder>('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<Types.PaginatedRequest> = {
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<HTMLInputElement>): 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<HTMLInputElement>): void => {
const newLimit = parseInt(event.target.value, 10);
setLimit(newLimit);
setPage(0);
fetchJobs(0, searchQuery);
};
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>): void => {
if (event.target.checked) {
const newSelected = new Set<string>(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' ? (
<ArrowUpIcon fontSize="small" />
) : (
<ArrowDownIcon fontSize="small" />
);
};
const isSelected = (jobId: string): boolean => selectedJobs.has(jobId);
const numSelected = selectedJobs.size;
const rowCount = jobs.length;
if (error) {
return (
<Paper sx={{ p: 2 }}>
<Alert severity="error">{error}</Alert>
</Paper>
);
}
const tableContent = (
<>
<Box sx={{ p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" component="h2">
Jobs ({total})
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
fullWidth
size="small"
placeholder="Search jobs..."
value={searchQuery}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Sort</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort"
onChange={(e): void => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
setSortField(field);
setSortOrder(order);
}}
>
<MenuItem value="updatedAt-desc">Updated </MenuItem>
<MenuItem value="updatedAt-asc">Updated </MenuItem>
<MenuItem value="createdAt-desc">Created </MenuItem>
<MenuItem value="createdAt-asc">Created </MenuItem>
<MenuItem value="company-asc">Company A-Z</MenuItem>
<MenuItem value="company-desc">Company Z-A</MenuItem>
<MenuItem value="title-asc">Title A-Z</MenuItem>
<MenuItem value="title-desc">Title Z-A</MenuItem>
</Select>
</FormControl>
</Box>
</Grid>
</Grid>
</Box>
<TableContainer>
<Table size="small" aria-label="jobs table">
<TableHead>
<TableRow>
{selectable && (
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAll}
/>
</TableCell>
)}
<TableCell sx={{ cursor: 'pointer' }} onClick={(): void => handleSort('company')}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BusinessIcon fontSize="small" />
Company {getSortIcon('company')}
</Box>
</TableCell>
<TableCell sx={{ cursor: 'pointer' }} onClick={(): void => handleSort('title')}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WorkIcon fontSize="small" />
Title {getSortIcon('title')}
</Box>
</TableCell>
<TableCell>Description</TableCell>
<TableCell>Owner</TableCell>
<TableCell sx={{ cursor: 'pointer' }} onClick={(): void => handleSort('updatedAt')}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ScheduleIcon fontSize="small" />
Updated {getSortIcon('updatedAt')}
</Box>
</TableCell>
<TableCell>Status</TableCell>
{showActions && <TableCell align="center">Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center">
<CircularProgress size={24} />
</TableCell>
</TableRow>
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center">
<Typography variant="body2" color="textSecondary">
No jobs found
</Typography>
</TableCell>
</TableRow>
) : (
jobs.map(job => {
const isItemSelected = isSelected(job.id || '');
return (
<TableRow
key={job.id}
hover
selected={isItemSelected || selectedJob?.id === job.id}
onClick={(): void => handleJobRowClick(job)}
sx={{ cursor: 'pointer' }}
>
{selectable && (
<TableCell padding="checkbox" onClick={(e): void => e.stopPropagation()}>
<Checkbox
color="primary"
checked={isItemSelected}
onChange={(): void => handleSelectJob(job.id || '')}
/>
</TableCell>
)}
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.company}
</Typography>
{job.details?.location && (
<Typography variant="caption" color="text.secondary">
{job.details.location.city}, {job.details.location.state}
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.title}
</Typography>
{job.details?.employmentType && (
<Chip
label={job.details.employmentType}
size="small"
variant="outlined"
sx={{ mt: 0.5 }}
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2" color="textSecondary">
{truncateDescription(job.description)}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{getOwnerName(job.owner)}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{formatDate(job.updatedAt)}</Typography>
</TableCell>
<TableCell>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
/>
</TableCell>
{showActions && (
<TableCell align="center" onClick={(e): void => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{onJobView && (
<Tooltip title="View Job">
<IconButton size="small" onClick={(): void => onJobView(job)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{onJobEdit && (
<Tooltip title="Edit Job">
<IconButton size="small" onClick={(): void => onJobEdit(job)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{onJobDelete && (
<Tooltip title="Delete Job">
<IconButton size="small" onClick={(): void => onJobDelete(job)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 50, 100]}
component="div"
count={total}
rowsPerPage={limit}
page={page}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</>
);
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'row', position: 'relative' }}>
<Scrollable
sx={{ display: 'flex', flex: 1, flexDirection: 'column', height: '100%', width: '100%' }}
>
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>{tableContent}</Paper>
</Scrollable>
{detailsPanelOpen && !isMobile && (
<Scrollable
sx={{ display: 'flex', flex: 1, flexDirection: 'row', height: '100%', width: '100%' }}
>
<Paper sx={{ flex: 1, ml: 1 }}>
{selectedJob ? (
<JobInfoPanel job={selectedJob} onClose={(): void => setSelectedJob(null)} />
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'text.secondary',
textAlign: 'center',
p: 2,
}}
>
<Typography variant="body2">Select a job to view details</Typography>
</Box>
)}
</Paper>
</Scrollable>
)}
<Dialog
fullScreen
open={mobileDialogOpen}
onClose={(): void => setMobileDialogOpen(false)}
TransitionComponent={Transition}
>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={(): void => setMobileDialogOpen(false)}
>
<ArrowBackIcon />
</IconButton>
<Box sx={{ ml: 2, flex: 1 }}>
<Typography variant="h6" noWrap>
{selectedJob?.title}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8 }} noWrap>
{selectedJob?.company}
</Typography>
</Box>
</Toolbar>
</AppBar>
{selectedJob && <JobInfoPanel job={selectedJob} inDialog />}
</Dialog>
</Box>
);
};
export { JobsView };

View File

@ -5,6 +5,8 @@
}
.a4-document {
/* display: flex; */
/* position: relative; */
/* A4 dimensions: 210mm x 297mm */
width: 210mm;
min-height: 297mm;

View File

@ -594,8 +594,30 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}}
content={editContent}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
fontSize: '0.8rem',
pb: 2,
}}
>
See my full Backstory at...
<Box
component="img"
src={`/api/1.0/candidates/qr-code/${activeResume.candidateId || ''}`}
alt="QR Code"
className="qr-code"
/>
{activeResume.candidate?.username
? `https://backstory.ketrenos.com/u/${activeResume.candidate?.username}`
: 'backstory'}
</Box>
<Box sx={{ p: 2 }}>&nbsp;</Box>
</Box>
<Box sx={{ pb: 2 }}>&nbsp;</Box>
</Box>
)}
</Scrollable>

View File

@ -76,6 +76,30 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ 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<void> => {
try {
let results;
@ -91,22 +115,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ 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<ResumeViewerProps> = ({ onSelect, candidateId, jobI
onSelect,
sortField,
sortOrder,
resumes.length,
]);
// Filter resumes based on search query

View File

@ -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: <WorkIcon />,
component: <JobAnalysisPage />,
userTypes: ['guest', 'candidate', 'employer'],
@ -127,7 +128,8 @@ export const navigationConfig: NavigationConfig = {
label: 'Jobs',
path: '/candidate/jobs/:jobId?',
icon: <WorkIcon />,
component: <JobViewer />,
component: <JobsView />,
variant: 'fullWidth',
userTypes: ['candidate', 'guest', 'employer'],
showInNavigation: false,
showInUserMenu: true,

View File

@ -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<BackstoryPageProps> = (_props: BackstoryPageProp
</Tabs>
</Box>
{jobTab === 'select' && <JobsTable onJobSelect={onJobsSelected} />}
{jobTab === 'select' && <JobsView selectable={false} onJobSelect={onJobsSelected} />}
{jobTab === 'create' && user && (
<JobCreator
onSave={(job): void => {

View File

@ -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({

View File

@ -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(