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
|
||||
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 \
|
||||
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,2 +1,6 @@
|
||||
src/types/*
|
||||
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
coverage
|
||||
|
@ -1,10 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"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"
|
||||
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">
|
||||
|
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 {
|
||||
/* display: flex; */
|
||||
/* position: relative; */
|
||||
/* A4 dimensions: 210mm x 297mm */
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
|
@ -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 }}> </Box>
|
||||
</Box>
|
||||
<Box sx={{ pb: 2 }}> </Box>
|
||||
</Box>
|
||||
)}
|
||||
</Scrollable>
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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 => {
|
||||
|
@ -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({
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user