Tweaked JobViewer for mobile

This commit is contained in:
James Ketr 2025-06-12 07:49:21 -07:00
parent 85eac72750
commit 0bc9f74c7f
3 changed files with 412 additions and 169 deletions

View File

@ -171,7 +171,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path), action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'profile' group: 'profile'
}); });
} }
@ -195,7 +195,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path), action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'account' group: 'account'
}); });
} }
@ -219,7 +219,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path), action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'admin' group: 'admin'
}); });
} }
@ -254,7 +254,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path), action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'system' group: 'system'
}); });
} }
@ -267,7 +267,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path), action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'other' group: 'other'
}); });
} }
@ -328,7 +328,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Navigation handlers // Navigation handlers
const handleNavigate = (path: string) => { const handleNavigate = (path: string) => {
navigate(path); navigate(path.replace(/:.*$/, ''));
setMobileOpen(false); setMobileOpen(false);
// Close all dropdowns // Close all dropdowns
setDropdownAnchors({}); setDropdownAnchors({});
@ -372,6 +372,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
onClick={() => child.path && handleNavigate(child.path)} onClick={() => child.path && handleNavigate(child.path)}
selected={isCurrentPath(child)} selected={isCurrentPath(child)}
disabled={!child.path} disabled={!child.path}
sx={{ display: 'flex', alignItems: 'center', "& *": { m: 0, p: 0 }, m: 0 }}
> >
{child.icon && <ListItemIcon>{child.icon}</ListItemIcon>} {child.icon && <ListItemIcon>{child.icon}</ListItemIcon>}
<ListItemText>{child.label}</ListItemText> <ListItemText>{child.label}</ListItemText>
@ -487,7 +488,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
item.group === 'divider' ? ( item.group === 'divider' ? (
<Divider key={`divider-${index}`} /> <Divider key={`divider-${index}`} />
) : ( ) : (
<ListItem key={item.id} disablePadding> <ListItem key={item.id}>
<ListItemButton onClick={() => handleUserMenuAction(item)}> <ListItemButton onClick={() => handleUserMenuAction(item)}>
{item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>} {item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>}
<ListItemText primary={item.label} /> <ListItemText primary={item.label} />

View File

@ -14,17 +14,24 @@ import {
MenuItem, MenuItem,
InputLabel, InputLabel,
Chip, Chip,
Divider,
IconButton, IconButton,
Tooltip Dialog,
AppBar,
Toolbar,
useMediaQuery,
useTheme,
Slide
} from '@mui/material'; } from '@mui/material';
import { import {
KeyboardArrowUp as ArrowUpIcon, KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon, KeyboardArrowDown as ArrowDownIcon,
Business as BusinessIcon, Business as BusinessIcon,
Work as WorkIcon, Work as WorkIcon,
Schedule as ScheduleIcon Schedule as ScheduleIcon,
Close as CloseIcon,
ArrowBack as ArrowBackIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { TransitionProps } from '@mui/material/transitions';
import { JobInfo } from 'components/ui/JobInfo'; import { JobInfo } from 'components/ui/JobInfo';
import { Job } from "types/types"; import { Job } from "types/types";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
@ -38,8 +45,21 @@ interface JobViewerProps {
onSelect?: (job: Job) => void; 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 JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { selectedJob, setSelectedJob } = useSelectedJob(); const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
@ -47,6 +67,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt'); const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc'); const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const { jobId } = useParams<{ jobId?: string }>(); const { jobId } = useParams<{ jobId?: string }>();
useEffect(() => { useEffect(() => {
@ -56,6 +77,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const results = await apiClient.getJobs(); const results = await apiClient.getJobs();
const jobsData: Job[] = results.data || []; const jobsData: Job[] = results.data || [];
setJobs(jobsData); setJobs(jobsData);
if (jobId) { if (jobId) {
const job = jobsData.find(j => j.id === jobId); const job = jobsData.find(j => j.id === jobId);
if (job) { if (job) {
@ -125,7 +147,15 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const handleJobSelect = (job: Job) => { const handleJobSelect = (job: Job) => {
setSelectedJob(job); setSelectedJob(job);
onSelect?.(job); onSelect?.(job);
if (isMobile) {
setMobileDialogOpen(true);
} else {
navigate(`/candidate/jobs/${job.id}`); navigate(`/candidate/jobs/${job.id}`);
}
};
const handleMobileDialogClose = () => {
setMobileDialogOpen(false);
}; };
const sortedJobs = sortJobs(jobs, sortField, sortOrder); const sortedJobs = sortJobs(jobs, sortField, sortOrder);
@ -135,9 +165,8 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', ...(isMobile ? {} : { year: 'numeric' }),
hour: '2-digit', ...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' })
minute: '2-digit'
}).format(date); }).format(date);
}; };
@ -146,16 +175,34 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />; return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
}; };
return ( const JobList = () => (
<Box sx={{ display: 'flex', height: '100%', gap: 2, p: 2, position: 'relative' }}> <Paper
{/* Left Panel - Job List */} elevation={isMobile ? 0 : 1}
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}> sx={{
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}> display: 'flex',
<Typography variant="h6" gutterBottom> flexDirection: 'column',
...(isMobile ? {
width: '100%',
boxShadow: 'none',
backgroundColor: 'transparent'
} : { width: '50%' })
}}
>
<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}) Jobs ({jobs.length})
</Typography> </Typography>
<FormControl size="small" sx={{ minWidth: 200 }}> <FormControl size="small" sx={{ minWidth: isSmall ? 120 : isMobile ? 150 : 200 }}>
<InputLabel>Sort by</InputLabel> <InputLabel>Sort by</InputLabel>
<Select <Select
value={`${sortField}-${sortOrder}`} value={`${sortField}-${sortOrder}`}
@ -178,41 +225,80 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
</FormControl> </FormControl>
</Box> </Box>
<TableContainer sx={{ flex: 1, overflow: 'auto' }}> <TableContainer sx={{
flex: 1,
overflow: 'auto',
'& .MuiTable-root': {
tableLayout: isMobile ? 'fixed' : 'auto'
}
}}>
<Table stickyHeader size="small"> <Table stickyHeader size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell <TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }} sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '25%' : 'auto',
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('company')} onClick={() => handleSort('company')}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BusinessIcon fontSize="small" /> <BusinessIcon fontSize={isMobile ? "small" : "medium"} />
Company <Typography variant="caption" fontWeight="bold" noWrap>
{isSmall ? 'Co.' : isMobile ? 'Company' : 'Company'}
</Typography>
{getSortIcon('company')} {getSortIcon('company')}
</Box> </Box>
</TableCell> </TableCell>
<TableCell <TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }} sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '45%' : 'auto',
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('title')} onClick={() => handleSort('title')}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WorkIcon fontSize="small" /> <WorkIcon fontSize={isMobile ? "small" : "medium"} />
Title <Typography variant="caption" fontWeight="bold" noWrap>Title</Typography>
{getSortIcon('title')} {getSortIcon('title')}
</Box> </Box>
</TableCell> </TableCell>
{!isMobile && (
<TableCell <TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }} sx={{
cursor: 'pointer',
userSelect: 'none',
py: 0.5,
px: 1,
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('updatedAt')} onClick={() => handleSort('updatedAt')}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ScheduleIcon fontSize="small" /> <ScheduleIcon fontSize="medium" />
Updated <Typography variant="caption" fontWeight="bold">Updated</Typography>
{getSortIcon('updatedAt')} {getSortIcon('updatedAt')}
</Box> </Box>
</TableCell> </TableCell>
<TableCell>Status</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> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -224,50 +310,100 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
onClick={() => handleJobSelect(job)} onClick={() => handleJobSelect(job)}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
height: isMobile ? 48 : 'auto',
'&.Mui-selected': { '&.Mui-selected': {
backgroundColor: 'action.selected', backgroundColor: 'action.selected',
},
'&:hover': {
backgroundColor: 'action.hover',
} }
}} }}
> >
<TableCell> <TableCell sx={{
<Typography variant="body2" fontWeight="medium"> 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'} {job.company || 'N/A'}
</Typography> </Typography>
{job.details?.location && ( {!isMobile && job.details?.location && (
<Typography variant="caption" color="text.secondary"> <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} {job.details.location.city}, {job.details.location.state || job.details.location.country}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell sx={{
<Typography variant="body2" fontWeight="medium"> 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'} {job.title || 'N/A'}
</Typography> </Typography>
{job.details?.employmentType && ( {!isMobile && job.details?.employmentType && (
<Chip <Chip
label={job.details.employmentType} label={job.details.employmentType}
size="small" size="small"
variant="outlined" variant="outlined"
sx={{ mt: 0.5, fontSize: '0.7rem', height: 20 }} sx={{
mt: 0.25,
fontSize: '0.6rem',
height: 16,
'& .MuiChip-label': { px: 0.5 }
}}
/> />
)} )}
</TableCell> </TableCell>
<TableCell> {!isMobile && (
<Typography variant="body2"> <TableCell sx={{ py: 0.5, px: 1 }}>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{formatDate(job.updatedAt)} {formatDate(job.updatedAt)}
</Typography> </Typography>
{job.createdAt && ( {job.createdAt && (
<Typography variant="caption" color="text.secondary"> <Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }}
>
Created: {formatDate(job.createdAt)} Created: {formatDate(job.createdAt)}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>
<TableCell> )}
<TableCell sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden'
}}>
<Chip <Chip
label={job.details?.isActive ? "Active" : "Inactive"} label={job.details?.isActive ? "Active" : "Inactive"}
color={job.details?.isActive ? "success" : "default"} color={job.details?.isActive ? "success" : "default"}
size="small" size="small"
variant="outlined" variant="outlined"
sx={{
fontSize: isMobile ? '0.65rem' : '0.7rem',
height: isMobile ? 20 : 22,
'& .MuiChip-label': {
px: isMobile ? 0.5 : 0.75,
py: 0
}
}}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -276,16 +412,15 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
</Table> </Table>
</TableContainer> </TableContainer>
</Paper> </Paper>
);
{/* Right Panel - Job Details */} const JobDetails = ({ inDialog = false }: { inDialog?: boolean }) => (
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}> <Box sx={{
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}> flex: 1,
<Typography variant="h6"> overflow: 'auto',
Job Details p: inDialog ? 1.5 : 0.75,
</Typography> height: inDialog ? '100%' : 'auto'
</Box> }}>
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{selectedJob ? ( {selectedJob ? (
<JobInfo <JobInfo
job={selectedJob} job={selectedJob}
@ -293,7 +428,10 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
sx={{ sx={{
border: 'none', border: 'none',
boxShadow: 'none', boxShadow: 'none',
backgroundColor: 'transparent' backgroundColor: 'transparent',
'& .MuiTypography-h6': {
fontSize: inDialog ? '1.25rem' : '1.1rem'
}
}} }}
/> />
) : ( ) : (
@ -302,14 +440,98 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '100%', height: '100%',
color: 'text.secondary' color: 'text.secondary',
textAlign: 'center',
p: 2
}}> }}>
<Typography variant="body1"> <Typography variant="body2">
Select a job to view details Select a job to view details
</Typography> </Typography>
</Box> </Box>
)} )}
</Box> </Box>
);
if (isMobile) {
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>
);
}
return (
<Box sx={{
display: 'flex',
height: '100%',
gap: 0.75,
p: 0.75,
backgroundColor: 'background.default'
}}>
<JobList />
<Paper sx={{
width: '50%',
display: 'flex',
flexDirection: 'column',
elevation: 1
}}>
<Box sx={{
p: 0.75,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'background.paper'
}}>
<Typography variant="h6" sx={{ fontSize: '1.1rem', fontWeight: 600 }}>
Job Details
</Typography>
</Box>
<JobDetails />
</Paper> </Paper>
</Box> </Box>
); );

View File

@ -44,6 +44,7 @@ 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 { JobViewer } from "components/ui/JobViewer";
import { CandidatePicker } from "components/ui/CandidatePicker";
// Beta page components for placeholder routes // Beta page components for placeholder routes
const BackstoryPage = () => ( const BackstoryPage = () => (
@ -129,6 +130,36 @@ export const navigationConfig: NavigationConfig = {
component: <CandidateChatPage />, component: <CandidateChatPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ["guest", "candidate", "employer"],
}, },
{
id: "explore",
label: "Explore",
icon: <SearchIcon />,
userTypes: ["candidate", "guest", "employer"],
children: [
// {
// id: "explore-candidates",
// label: "Candidates",
// path: "/candidate/candidates",
// icon: <SearchIcon />,
// component: (
// <CandidatePicker />
// ),
// userTypes: ["candidate", "guest", "employer"],
// },
{
id: "explore-jobs",
label: "Jobs",
path: "/candidate/jobs/:jobId?",
icon: <SearchIcon />,
component: (
<JobViewer />
),
userTypes: ["candidate", "guest", "employer"],
},
],
showInNavigation: true,
},
{ {
id: "generate-candidate", id: "generate-candidate",
label: "Generate Candidate", label: "Generate Candidate",
@ -163,17 +194,6 @@ export const navigationConfig: NavigationConfig = {
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
}, },
{
id: "candidate-jobs",
label: "Jobs",
icon: <WorkIcon />,
path: "/candidate/jobs/:jobId?",
component: <JobViewer />,
userTypes: ["candidate", "guest", "employer"],
userMenuGroup: "profile",
showInNavigation: false,
showInUserMenu: true,
},
{ {
id: "candidate-docs", id: "candidate-docs",
label: "Content", label: "Content",