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

View File

@ -14,17 +14,24 @@ import {
MenuItem,
InputLabel,
Chip,
Divider,
IconButton,
Tooltip
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
Schedule as ScheduleIcon,
Close as CloseIcon,
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';
@ -38,8 +45,21 @@ 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();
@ -47,6 +67,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
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(() => {
@ -56,6 +77,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const results = await apiClient.getJobs();
const jobsData: Job[] = results.data || [];
setJobs(jobsData);
if (jobId) {
const job = jobsData.find(j => j.id === jobId);
if (job) {
@ -125,7 +147,15 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const handleJobSelect = (job: Job) => {
setSelectedJob(job);
onSelect?.(job);
navigate(`/candidate/jobs/${job.id}`);
if (isMobile) {
setMobileDialogOpen(true);
} else {
navigate(`/candidate/jobs/${job.id}`);
}
};
const handleMobileDialogClose = () => {
setMobileDialogOpen(false);
};
const sortedJobs = sortJobs(jobs, sortField, sortOrder);
@ -135,9 +165,8 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
...(isMobile ? {} : { year: 'numeric' }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' })
}).format(date);
};
@ -146,170 +175,363 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
};
return (
<Box sx={{ display: 'flex', height: '100%', gap: 2, p: 2, position: 'relative' }}>
{/* Left Panel - Job List */}
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="h6" gutterBottom>
Jobs ({jobs.length})
</Typography>
const JobList = () => (
<Paper
elevation={isMobile ? 0 : 1}
sx={{
display: 'flex',
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})
</Typography>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort by"
onChange={(e) => {
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>
<FormControl size="small" sx={{ minWidth: isSmall ? 120 : isMobile ? 150 : 200 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort by"
onChange={(e) => {
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' }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<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={() => 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={() => 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' }}
onClick={() => handleSort('company')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BusinessIcon fontSize="small" />
Company
{getSortIcon('company')}
</Box>
</TableCell>
<TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('title')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WorkIcon fontSize="small" />
Title
{getSortIcon('title')}
</Box>
</TableCell>
<TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }}
sx={{
cursor: 'pointer',
userSelect: 'none',
py: 0.5,
px: 1,
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('updatedAt')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ScheduleIcon fontSize="small" />
Updated
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ScheduleIcon fontSize="medium" />
<Typography variant="caption" fontWeight="bold">Updated</Typography>
{getSortIcon('updatedAt')}
</Box>
</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedJobs.map((job) => (
<TableRow
key={job.id}
hover
selected={selectedJob?.id === job.id}
onClick={() => handleJobSelect(job)}
sx={{
cursor: 'pointer',
'&.Mui-selected': {
backgroundColor: 'action.selected',
}
}}
>
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.company || 'N/A'}
)}
<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={() => 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>
{job.details?.location && (
<Typography variant="caption" color="text.secondary">
{job.details.location.city}, {job.details.location.state || job.details.location.country}
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.title || 'N/A'}
</Typography>
{job.details?.employmentType && (
<Chip
label={job.details.employmentType}
size="small"
variant="outlined"
sx={{ mt: 0.5, fontSize: '0.7rem', height: 20 }}
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2">
)}
</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">
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }}
>
Created: {formatDate(job.createdAt)}
</Typography>
)}
</TableCell>
<TableCell>
<Chip
label={job.details?.isActive ? "Active" : "Inactive"}
color={job.details?.isActive ? "success" : "default"}
size="small"
variant="outlined"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
<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>
);
{/* Right Panel - Job Details */}
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="h6">
const JobDetails = ({ inDialog = false }: { inDialog?: boolean }) => (
<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>
);
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>
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{selectedJob ? (
<JobInfo
job={selectedJob}
variant="all"
sx={{
border: 'none',
boxShadow: 'none',
backgroundColor: 'transparent'
}}
/>
) : (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'text.secondary'
}}>
<Typography variant="body1">
Select a job to view details
</Typography>
</Box>
)}
</Box>
<JobDetails />
</Paper>
</Box>
);

View File

@ -44,6 +44,7 @@ import { DocumentManager } from "components/DocumentManager";
import { useAuth } from "hooks/AuthContext";
import { useNavigate } from "react-router-dom";
import { JobViewer } from "components/ui/JobViewer";
import { CandidatePicker } from "components/ui/CandidatePicker";
// Beta page components for placeholder routes
const BackstoryPage = () => (
@ -129,6 +130,36 @@ export const navigationConfig: NavigationConfig = {
component: <CandidateChatPage />,
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",
label: "Generate Candidate",
@ -163,17 +194,6 @@ export const navigationConfig: NavigationConfig = {
showInNavigation: false,
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",
label: "Content",