Added qr code generation
This commit is contained in:
parent
f3b9e0c2e7
commit
0c32e26955
@ -196,6 +196,9 @@ RUN pip install pyyaml user-agents cryptography
|
|||||||
# OpenAPI CLI generator
|
# OpenAPI CLI generator
|
||||||
RUN pip install openapi-python-client
|
RUN pip install openapi-python-client
|
||||||
|
|
||||||
|
# QR code generator
|
||||||
|
RUN pip install pyqrcode pypng
|
||||||
|
|
||||||
# Automatic type conversion pydantic -> typescript
|
# Automatic type conversion pydantic -> typescript
|
||||||
RUN pip install pydantic typing-inspect jinja2
|
RUN pip install pydantic typing-inspect jinja2
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
@ -1,42 +1,24 @@
|
|||||||
{
|
{
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"jest": true
|
"es2021": true,
|
||||||
|
"node": true
|
||||||
},
|
},
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
|
||||||
"plugin:prettier/recommended"
|
"plugin:prettier/recommended"
|
||||||
],
|
],
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"plugins": ["@typescript-eslint", "react", "react-hooks"],
|
||||||
"ecmaVersion": 2021,
|
|
||||||
"sourceType": "module",
|
|
||||||
"ecmaFeatures": {
|
|
||||||
"jsx": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"@typescript-eslint",
|
|
||||||
"react",
|
|
||||||
"react-hooks",
|
|
||||||
"prettier"
|
|
||||||
],
|
|
||||||
"rules": {
|
"rules": {
|
||||||
"react/prop-types": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
"@typescript-eslint/explicit-function-return-type": "warn",
|
"prettier/prettier": ["error", { "arrowParens": "avoid" }]
|
||||||
"no-unused-vars": "off",
|
|
||||||
"prettier/prettier": "error",
|
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }]
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"react": {
|
"react": {
|
||||||
"version": "detect"
|
"version": "detect"
|
||||||
},
|
|
||||||
"import/resolver": {
|
|
||||||
"typescript": {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,6 @@
|
|||||||
src/types/*
|
src/types/*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"trailingComma": "es5",
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"arrowParens": "avoid"
|
"arrowParens": "avoid"
|
||||||
}
|
}
|
||||||
|
@ -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 };
|
|
@ -255,7 +255,13 @@ const JobsTable: React.FC<JobsTableProps> = ({
|
|||||||
role="checkbox"
|
role="checkbox"
|
||||||
aria-checked={isItemSelected}
|
aria-checked={isItemSelected}
|
||||||
selected={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 && (
|
{selectable && (
|
||||||
<TableCell padding="checkbox">
|
<TableCell padding="checkbox">
|
||||||
|
659
frontend/src/components/ui/JobsView.tsx
Normal file
659
frontend/src/components/ui/JobsView.tsx
Normal 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 };
|
@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.a4-document {
|
.a4-document {
|
||||||
|
/* display: flex; */
|
||||||
|
/* position: relative; */
|
||||||
/* A4 dimensions: 210mm x 297mm */
|
/* A4 dimensions: 210mm x 297mm */
|
||||||
width: 210mm;
|
width: 210mm;
|
||||||
min-height: 297mm;
|
min-height: 297mm;
|
||||||
|
@ -594,8 +594,30 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
}}
|
}}
|
||||||
content={editContent}
|
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>
|
</Box>
|
||||||
<Box sx={{ p: 2 }}> </Box>
|
<Box sx={{ pb: 2 }}> </Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Scrollable>
|
</Scrollable>
|
||||||
|
@ -76,6 +76,30 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
|||||||
const { resumeId } = useParams<{ resumeId?: string }>();
|
const { resumeId } = useParams<{ resumeId?: string }>();
|
||||||
|
|
||||||
useEffect(() => {
|
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> => {
|
const getResumes = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
let results;
|
let results;
|
||||||
@ -91,22 +115,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
|||||||
const resumesData: Resume[] = results.resumes || [];
|
const resumesData: Resume[] = results.resumes || [];
|
||||||
setResumes(resumesData);
|
setResumes(resumesData);
|
||||||
setFilteredResumes(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) {
|
} catch (err) {
|
||||||
console.error('Failed to load resumes:', err);
|
console.error('Failed to load resumes:', err);
|
||||||
setSnack('Failed to load resumes: ' + err, 'error');
|
setSnack('Failed to load resumes: ' + err, 'error');
|
||||||
@ -125,6 +133,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
|||||||
onSelect,
|
onSelect,
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
resumes.length,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter resumes based on search query
|
// Filter resumes based on search query
|
||||||
|
@ -27,7 +27,7 @@ import { VectorVisualizer } from 'components/VectorVisualizer';
|
|||||||
import { DocumentManager } from 'components/DocumentManager';
|
import { DocumentManager } from 'components/DocumentManager';
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { ResumeViewer } from 'components/ui/ResumeViewer';
|
||||||
|
|
||||||
import { JobsTable } from 'components/ui/JobsTable';
|
import { JobsTable } from 'components/ui/JobsTable';
|
||||||
@ -56,6 +56,7 @@ export const navigationConfig: NavigationConfig = {
|
|||||||
id: 'job-analysis',
|
id: 'job-analysis',
|
||||||
label: 'Job Analysis',
|
label: 'Job Analysis',
|
||||||
path: '/job-analysis',
|
path: '/job-analysis',
|
||||||
|
variant: 'fullWidth',
|
||||||
icon: <WorkIcon />,
|
icon: <WorkIcon />,
|
||||||
component: <JobAnalysisPage />,
|
component: <JobAnalysisPage />,
|
||||||
userTypes: ['guest', 'candidate', 'employer'],
|
userTypes: ['guest', 'candidate', 'employer'],
|
||||||
@ -127,7 +128,8 @@ export const navigationConfig: NavigationConfig = {
|
|||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
path: '/candidate/jobs/:jobId?',
|
path: '/candidate/jobs/:jobId?',
|
||||||
icon: <WorkIcon />,
|
icon: <WorkIcon />,
|
||||||
component: <JobViewer />,
|
component: <JobsView />,
|
||||||
|
variant: 'fullWidth',
|
||||||
userTypes: ['candidate', 'guest', 'employer'],
|
userTypes: ['candidate', 'guest', 'employer'],
|
||||||
showInNavigation: false,
|
showInNavigation: false,
|
||||||
showInUserMenu: true,
|
showInUserMenu: true,
|
||||||
|
@ -30,7 +30,7 @@ import { JobCreator } from 'components/JobCreator';
|
|||||||
import { LoginRestricted } from 'components/ui/LoginRestricted';
|
import { LoginRestricted } from 'components/ui/LoginRestricted';
|
||||||
import { ResumeGenerator } from 'components/ResumeGenerator';
|
import { ResumeGenerator } from 'components/ResumeGenerator';
|
||||||
import { JobInfo } from 'components/ui/JobInfo';
|
import { JobInfo } from 'components/ui/JobInfo';
|
||||||
import { JobsTable } from 'components/ui/JobsTable';
|
import { JobsView } from 'components/ui/JobsView';
|
||||||
|
|
||||||
function WorkAddIcon(): JSX.Element {
|
function WorkAddIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
@ -246,7 +246,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (_props: BackstoryPageProp
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{jobTab === 'select' && <JobsTable onJobSelect={onJobsSelected} />}
|
{jobTab === 'select' && <JobsView selectable={false} onJobSelect={onJobsSelected} />}
|
||||||
{jobTab === 'create' && user && (
|
{jobTab === 'create' && user && (
|
||||||
<JobCreator
|
<JobCreator
|
||||||
onSave={(job): void => {
|
onSave={(job): void => {
|
||||||
|
@ -41,6 +41,8 @@ from utils.auth_utils import (
|
|||||||
SecurityConfig,
|
SecurityConfig,
|
||||||
validate_password_strength,
|
validate_password_strength,
|
||||||
)
|
)
|
||||||
|
import defines
|
||||||
|
import pyqrcode
|
||||||
|
|
||||||
# Create router for authentication endpoints
|
# Create router for authentication endpoints
|
||||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
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(employer.email, user_auth_data)
|
||||||
await database.set_user(username, user_auth_data)
|
await database.set_user(username, user_auth_data)
|
||||||
await database.set_user_by_id(employer.id, 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
|
# Mark as verified
|
||||||
await database.mark_email_verified(request.token)
|
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']}")
|
logger.info(f"✅ Email verified and account activated for: {verification_data['email']}")
|
||||||
|
|
||||||
return create_success_response({
|
return create_success_response({
|
||||||
|
@ -712,6 +712,44 @@ async def get_candidate_profile_image(
|
|||||||
status_code=500, content=create_error_response("FETCH_ERROR", "Failed to retrieve 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")
|
@router.get("/documents")
|
||||||
async def get_candidate_documents(
|
async def get_candidate_documents(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user