569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Box,
|
|
Link,
|
|
Typography,
|
|
Avatar,
|
|
Grid,
|
|
SxProps,
|
|
CardActions,
|
|
Chip,
|
|
Stack,
|
|
CardHeader,
|
|
Button,
|
|
LinearProgress,
|
|
IconButton,
|
|
Tooltip,
|
|
Card,
|
|
CardContent,
|
|
Divider,
|
|
useTheme,
|
|
useMediaQuery,
|
|
TextField,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Tabs,
|
|
Tab,
|
|
} from "@mui/material";
|
|
import PrintIcon from "@mui/icons-material/Print";
|
|
import {
|
|
Delete as DeleteIcon,
|
|
Restore as RestoreIcon,
|
|
Save as SaveIcon,
|
|
Edit as EditIcon,
|
|
Description as DescriptionIcon,
|
|
Work as WorkIcon,
|
|
Person as PersonIcon,
|
|
Schedule as ScheduleIcon,
|
|
Visibility as VisibilityIcon,
|
|
VisibilityOff as VisibilityOffIcon,
|
|
} from "@mui/icons-material";
|
|
import PreviewIcon from "@mui/icons-material/Preview";
|
|
import EditDocumentIcon from "@mui/icons-material/EditDocument";
|
|
|
|
import { useReactToPrint } from "react-to-print";
|
|
|
|
import { useAuth } from "hooks/AuthContext";
|
|
import { useAppState } from "hooks/GlobalContext";
|
|
import { StyledMarkdown } from "components/StyledMarkdown";
|
|
import { Resume } from "types/types";
|
|
import { BackstoryTextField } from "components/BackstoryTextField";
|
|
import { JobInfo } from "./JobInfo";
|
|
|
|
interface ResumeInfoProps {
|
|
resume: Resume;
|
|
sx?: SxProps;
|
|
action?: string;
|
|
elevation?: number;
|
|
variant?: "minimal" | "small" | "normal" | "all" | null;
|
|
}
|
|
|
|
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|
const { setSnack } = useAppState();
|
|
const { resume } = props;
|
|
const { user, apiClient } = useAuth();
|
|
const { sx, action = "", elevation = 1, variant = "normal" } = props;
|
|
const theme = useTheme();
|
|
const isMobile =
|
|
useMediaQuery(theme.breakpoints.down("md")) || variant === "minimal";
|
|
const isAdmin = user?.isAdmin;
|
|
const [activeResume, setActiveResume] = useState<Resume>({ ...resume });
|
|
const [isContentExpanded, setIsContentExpanded] = useState(false);
|
|
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
|
|
const [deleted, setDeleted] = useState<boolean>(false);
|
|
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
|
|
const [printDialogOpen, setPrintDialogOpen] = useState<boolean>(false);
|
|
const [editContent, setEditContent] = useState<string>("");
|
|
const [saving, setSaving] = useState<boolean>(false);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [tabValue, setTabValue] = useState("markdown");
|
|
const printContentRef = useRef<HTMLDivElement>(null);
|
|
const reactToPrintFn = useReactToPrint({
|
|
contentRef: printContentRef,
|
|
pageStyle: "@page { margin: 10px; }",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (resume && resume.id !== activeResume?.id) {
|
|
setActiveResume(resume);
|
|
}
|
|
}, [resume, activeResume]);
|
|
|
|
// Check if content needs truncation
|
|
useEffect(() => {
|
|
if (contentRef.current && resume.resume) {
|
|
const element = contentRef.current;
|
|
setShouldShowMoreButton(element.scrollHeight > element.clientHeight);
|
|
}
|
|
}, [resume.resume]);
|
|
|
|
const deleteResume = async (id: string | undefined) => {
|
|
if (id) {
|
|
try {
|
|
await apiClient.deleteResume(id);
|
|
setDeleted(true);
|
|
setSnack("Resume deleted successfully.");
|
|
} catch (error) {
|
|
setSnack("Failed to delete resume.");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleReset = async () => {
|
|
setActiveResume({ ...resume });
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const result = await apiClient.updateResume(
|
|
activeResume.id || "",
|
|
editContent
|
|
);
|
|
const updatedResume = {
|
|
...activeResume,
|
|
resume: editContent,
|
|
updatedAt: new Date(),
|
|
};
|
|
setActiveResume(updatedResume);
|
|
setSnack("Resume updated successfully.");
|
|
} catch (error) {
|
|
setSnack("Failed to update resume.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleEditOpen = () => {
|
|
setEditContent(activeResume.resume);
|
|
setEditDialogOpen(true);
|
|
};
|
|
|
|
if (!resume) {
|
|
return <Box>No resume provided.</Box>;
|
|
}
|
|
|
|
const formatDate = (date: Date | undefined) => {
|
|
if (!date) return "N/A";
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(date);
|
|
};
|
|
|
|
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
|
if (newValue === "print") {
|
|
reactToPrintFn();
|
|
return;
|
|
}
|
|
setTabValue(newValue);
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
borderColor: "transparent",
|
|
borderWidth: 2,
|
|
borderStyle: "solid",
|
|
transition: "all 0.3s ease",
|
|
flexDirection: "column",
|
|
minWidth: 0,
|
|
opacity: deleted ? 0.5 : 1.0,
|
|
backgroundColor: deleted
|
|
? theme.palette.action.disabledBackground
|
|
: theme.palette.background.paper,
|
|
pointerEvents: deleted ? "none" : "auto",
|
|
...sx,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexGrow: 1,
|
|
p: 1,
|
|
pb: 0,
|
|
height: "100%",
|
|
flexDirection: "column",
|
|
alignItems: "stretch",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{/* Header Information */}
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: isMobile ? "column" : "row",
|
|
gap: 2,
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Grid container spacing={2}>
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Stack spacing={1}>
|
|
{activeResume.candidate && (
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<PersonIcon color="primary" fontSize="small" />
|
|
<Typography variant="subtitle2" fontWeight="bold">
|
|
Candidate
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
<Typography variant="body2" color="text.secondary">
|
|
{activeResume.candidate?.fullName || activeResume.candidateId}
|
|
</Typography>
|
|
|
|
{activeResume.job && (
|
|
<>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1,
|
|
mt: 1,
|
|
}}
|
|
>
|
|
<WorkIcon color="primary" fontSize="small" />
|
|
<Typography variant="subtitle2" fontWeight="bold">
|
|
Job
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{activeResume.job.title} at {activeResume.job.company}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Stack spacing={1}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<ScheduleIcon color="primary" fontSize="small" />
|
|
<Typography variant="subtitle2" fontWeight="bold">
|
|
Timeline
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Created: {formatDate(activeResume.createdAt)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Updated: {formatDate(activeResume.updatedAt)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Resume ID: {activeResume.id}
|
|
</Typography>
|
|
</Stack>
|
|
</Grid>
|
|
</Grid>
|
|
</Box>
|
|
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
{/* Resume Content */}
|
|
{activeResume.resume && (
|
|
<Card
|
|
elevation={0}
|
|
sx={{ m: 0, p: 0, background: "transparent !important" }}
|
|
>
|
|
<CardHeader
|
|
title="Resume Content"
|
|
avatar={<DescriptionIcon color="success" />}
|
|
sx={{ p: 0, pb: 1 }}
|
|
action={
|
|
isAdmin && (
|
|
<Tooltip title="Edit Resume Content">
|
|
<IconButton size="small" onClick={handleEditOpen}>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
/>
|
|
<CardContent sx={{ p: 0 }}>
|
|
<Box sx={{ position: "relative" }}>
|
|
<Typography
|
|
ref={contentRef}
|
|
variant="body2"
|
|
component="div"
|
|
sx={{
|
|
display: "-webkit-box",
|
|
WebkitLineClamp: isContentExpanded
|
|
? "unset"
|
|
: variant === "small"
|
|
? 5
|
|
: variant === "minimal"
|
|
? 3
|
|
: 10,
|
|
WebkitBoxOrient: "vertical",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
lineHeight: 1.6,
|
|
fontSize: "0.875rem !important",
|
|
whiteSpace: "pre-wrap",
|
|
fontFamily: "monospace",
|
|
backgroundColor: theme.palette.action.hover,
|
|
p: 2,
|
|
borderRadius: 1,
|
|
border: `1px solid ${theme.palette.divider}`,
|
|
}}
|
|
>
|
|
{activeResume.resume}
|
|
</Typography>
|
|
|
|
{shouldShowMoreButton && variant !== "all" && (
|
|
<Box
|
|
sx={{ display: "flex", justifyContent: "center", mt: 1 }}
|
|
>
|
|
<Button
|
|
variant="text"
|
|
size="small"
|
|
onClick={() => setIsContentExpanded(!isContentExpanded)}
|
|
startIcon={
|
|
isContentExpanded ? (
|
|
<VisibilityOffIcon />
|
|
) : (
|
|
<VisibilityIcon />
|
|
)
|
|
}
|
|
sx={{ fontSize: "0.75rem" }}
|
|
>
|
|
{isContentExpanded ? "Show Less" : "Show More"}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{variant === "all" && activeResume.resume && (
|
|
<Box sx={{ mt: 2 }}>
|
|
<StyledMarkdown content={activeResume.resume} />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Admin Controls */}
|
|
{isAdmin && (
|
|
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
pl: 1,
|
|
pr: 1,
|
|
gap: 1,
|
|
alignContent: "center",
|
|
height: "32px",
|
|
}}
|
|
>
|
|
<Tooltip title="Edit Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleEditOpen();
|
|
}}
|
|
>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title="Delete Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
deleteResume(activeResume.id);
|
|
}}
|
|
>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title="Reset Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleReset();
|
|
}}
|
|
>
|
|
<RestoreIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{saving && (
|
|
<Box sx={{ mt: 1 }}>
|
|
<LinearProgress />
|
|
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
|
Saving resume...
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Print Dialog */}
|
|
<Dialog
|
|
open={printDialogOpen}
|
|
onClose={() => {}} //setPrintDialogOpen(false)}
|
|
maxWidth="lg"
|
|
fullWidth
|
|
fullScreen={true}
|
|
>
|
|
<StyledMarkdown
|
|
content={activeResume.resume}
|
|
sx={{
|
|
p: 2,
|
|
position: "relative",
|
|
maxHeight: "100%",
|
|
width: "100%",
|
|
display: "flex",
|
|
flexGrow: 1,
|
|
flex: 1 /* Take remaining space in some-container */,
|
|
overflowY: "auto" /* Scroll if content overflows */,
|
|
}}
|
|
/>
|
|
</Dialog>
|
|
|
|
{/* Edit Dialog */}
|
|
<Dialog
|
|
open={editDialogOpen}
|
|
onClose={() => setEditDialogOpen(false)}
|
|
maxWidth="lg"
|
|
fullWidth
|
|
disableEscapeKeyDown={true}
|
|
fullScreen={true}
|
|
>
|
|
<DialogTitle>
|
|
Edit Resume Content
|
|
<Typography variant="caption" display="block" color="text.secondary">
|
|
Resume for{" "}
|
|
{activeResume.candidate?.fullName || activeResume.candidateId},{" "}
|
|
{activeResume.job?.title || "No Job Title Assigned"},{" "}
|
|
{activeResume.job?.company || "No Company Assigned"}
|
|
</Typography>
|
|
<Typography variant="caption" display="block" color="text.secondary">
|
|
Resume ID: # {activeResume.id}
|
|
</Typography>
|
|
<Typography variant="caption" display="block" color="text.secondary">
|
|
Last saved:{" "}
|
|
{activeResume.updatedAt
|
|
? new Date(activeResume.updatedAt).toLocaleString()
|
|
: "N/A"}
|
|
</Typography>
|
|
</DialogTitle>
|
|
<DialogContent
|
|
sx={{
|
|
position: "relative",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
<Tabs value={tabValue} onChange={handleTabChange} centered>
|
|
<Tab
|
|
value="markdown"
|
|
icon={<EditDocumentIcon />}
|
|
label="Markdown"
|
|
/>
|
|
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
|
<Tab value="job" icon={<WorkIcon />} label="Job" />
|
|
<Tab value="print" icon={<PrintIcon />} label="Print" />
|
|
</Tabs>
|
|
<Box
|
|
ref={printContentRef}
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%" /* Restrict to main-container's height */,
|
|
width: "100%",
|
|
minHeight: 0 /* Prevent flex overflow */,
|
|
//maxHeight: "min-content",
|
|
"& > *:not(.Scrollable)": {
|
|
flexShrink: 0 /* Prevent shrinking */,
|
|
},
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{tabValue === "markdown" && (
|
|
<BackstoryTextField
|
|
value={editContent}
|
|
onChange={(value) => setEditContent(value)}
|
|
style={{
|
|
position: "relative",
|
|
// maxHeight: "100%",
|
|
height: "100%",
|
|
width: "100%",
|
|
display: "flex",
|
|
minHeight: "100%",
|
|
|
|
flexGrow: 1,
|
|
flex: 1 /* Take remaining space in some-container */,
|
|
overflowY: "auto" /* Scroll if content overflows */,
|
|
}}
|
|
placeholder="Enter resume content..."
|
|
/>
|
|
)}
|
|
{tabValue === "preview" && (
|
|
<>
|
|
<StyledMarkdown
|
|
sx={{
|
|
p: 2,
|
|
position: "relative",
|
|
maxHeight: "100%",
|
|
width: "100%",
|
|
display: "flex",
|
|
flexGrow: 1,
|
|
flex: 1 /* Take remaining space in some-container */,
|
|
overflowY: "auto" /* Scroll if content overflows */,
|
|
}}
|
|
content={editContent}
|
|
/>
|
|
<Box sx={{ pb: 2 }}></Box>
|
|
</>
|
|
)}
|
|
{tabValue === "job" && activeResume.job && (
|
|
<JobInfo
|
|
variant="all"
|
|
job={activeResume.job}
|
|
sx={{
|
|
p: 2,
|
|
position: "relative",
|
|
maxHeight: "100%",
|
|
width: "100%",
|
|
display: "flex",
|
|
flexGrow: 1,
|
|
flex: 1 /* Take remaining space in some-container */,
|
|
overflowY: "auto" /* Scroll if content overflows */,
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
variant="contained"
|
|
disabled={saving}
|
|
startIcon={<SaveIcon />}
|
|
>
|
|
{saving ? "Saving..." : "Save"}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { ResumeInfo };
|