Add attachment metadata and overview strategy snapshot
This commit is contained in:
@@ -27,7 +27,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize);
|
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);
|
||||||
|
|
||||||
// Child entities are accessed by raw integer ids in a few endpoints below.
|
// Child entities are accessed by raw integer ids in a few endpoints below.
|
||||||
// Always resolve them through the parent JobApplication query so the global job-level
|
// Always resolve them through the parent JobApplication query so the global job-level
|
||||||
@@ -46,6 +46,17 @@ namespace JobTrackerApi.Controllers
|
|||||||
return $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{suffix}{ext}";
|
return $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{suffix}{ext}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GuessPurpose(string fileName)
|
||||||
|
{
|
||||||
|
var n = (fileName ?? string.Empty).ToLowerInvariant();
|
||||||
|
if (n.Contains("cover")) return "cover-letter";
|
||||||
|
if (n.Contains("resume") || n.Contains("résumé") || n.Contains(" cv") || n.EndsWith("cv.pdf")) return "resume";
|
||||||
|
if (n.Contains("portfolio")) return "portfolio";
|
||||||
|
if (n.Contains("case") || n.Contains("sample")) return "case-study";
|
||||||
|
if (n.Contains("cert")) return "certificate";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{jobId:int}")]
|
[HttpGet("{jobId:int}")]
|
||||||
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -56,7 +67,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.JobApplicationId == jobId)
|
.Where(a => a.JobApplicationId == jobId)
|
||||||
.OrderByDescending(a => a.UploadDate)
|
.OrderByDescending(a => a.UploadDate)
|
||||||
.Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize))
|
.Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize, a.Purpose, a.UseForAi))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Ok(items);
|
return Ok(items);
|
||||||
@@ -76,17 +87,32 @@ namespace JobTrackerApi.Controllers
|
|||||||
return PhysicalFile(att.FilePath, contentType, fileName);
|
return PhysicalFile(att.FilePath, contentType, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record RenameAttachmentRequest(string FileName);
|
public sealed record UpdateAttachmentRequest(string? FileName, string? Purpose, bool? UseForAi);
|
||||||
|
|
||||||
[HttpPatch("{id:int}")]
|
[HttpPatch("{id:int}")]
|
||||||
public async Task<IActionResult> Rename([FromRoute] int id, [FromBody] RenameAttachmentRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> Rename([FromRoute] int id, [FromBody] UpdateAttachmentRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
|
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
|
||||||
if (att is null) return NotFound();
|
if (att is null) return NotFound();
|
||||||
|
|
||||||
var name = Path.GetFileName((request.FileName ?? "").Trim());
|
if (request.UseForAi is not null)
|
||||||
if (name.Length == 0) return BadRequest("FileName is required.");
|
{
|
||||||
|
att.UseForAi = request.UseForAi.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Purpose))
|
||||||
|
{
|
||||||
|
att.Purpose = request.Purpose.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawName = (request.FileName ?? string.Empty).Trim();
|
||||||
|
if (rawName.Length == 0)
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Path.GetFileName(rawName);
|
||||||
var ext = Path.GetExtension(name);
|
var ext = Path.GetExtension(name);
|
||||||
if (!AllowedExtensions.Contains(ext))
|
if (!AllowedExtensions.Contains(ext))
|
||||||
return BadRequest("That file type is not allowed.");
|
return BadRequest("That file type is not allowed.");
|
||||||
@@ -166,7 +192,9 @@ namespace JobTrackerApi.Controllers
|
|||||||
FilePath = path,
|
FilePath = path,
|
||||||
UploadDate = DateTime.Now,
|
UploadDate = DateTime.Now,
|
||||||
FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||||
FileSize = file.Length
|
FileSize = file.Length,
|
||||||
|
Purpose = GuessPurpose(displayName),
|
||||||
|
UseForAi = true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ namespace JobTrackerApi.Controllers
|
|||||||
{
|
{
|
||||||
query = query.Where(a => allowedIds.Contains(a.Id));
|
query = query.Where(a => allowedIds.Contains(a.Id));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
query = query.Where(a => a.UseForAi);
|
||||||
|
}
|
||||||
|
|
||||||
var attachments = await query
|
var attachments = await query
|
||||||
.OrderByDescending(a => a.UploadDate)
|
.OrderByDescending(a => a.UploadDate)
|
||||||
|
|||||||
@@ -676,6 +676,8 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
|||||||
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
|
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
||||||
|
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||||
|
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
||||||
|
|
||||||
// Ensure data folder exists before creating/opening SQLite files.
|
// Ensure data folder exists before creating/opening SQLite files.
|
||||||
Directory.CreateDirectory(paths.DataRoot);
|
Directory.CreateDirectory(paths.DataRoot);
|
||||||
@@ -730,6 +732,8 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
|||||||
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
|
||||||
|
|
||||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ namespace JobTrackerApi.Models
|
|||||||
public DateTime UploadDate { get; set; } = DateTime.Now;
|
public DateTime UploadDate { get; set; } = DateTime.Now;
|
||||||
public string FileType { get; set; } = ""; // e.g., PDF, DOCX
|
public string FileType { get; set; } = ""; // e.g., PDF, DOCX
|
||||||
public long FileSize { get; set; }
|
public long FileSize { get; set; }
|
||||||
|
public string? Purpose { get; set; }
|
||||||
|
public bool UseForAi { get; set; } = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Last updated: 2026-03-23
|
|||||||
- [x] Add frontend test coverage for AI service status rendering
|
- [x] Add frontend test coverage for AI service status rendering
|
||||||
- [x] Add CV text improvement flow powered by the AI service
|
- [x] Add CV text improvement flow powered by the AI service
|
||||||
- [x] Extend AI extraction to job attachment ingestion for package/follow-up context
|
- [x] Extend AI extraction to job attachment ingestion for package/follow-up context
|
||||||
|
- [x] Add attachment metadata and AI-inclusion controls for job documents
|
||||||
- [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*`
|
- [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*`
|
||||||
|
|
||||||
## Build / UI Issues
|
## Build / UI Issues
|
||||||
@@ -82,5 +83,6 @@ Last updated: 2026-03-23
|
|||||||
- [x] Reduce duplicated UI/data across multiple pages
|
- [x] Reduce duplicated UI/data across multiple pages
|
||||||
- [x] Add direct follow-up deep links for specific jobs
|
- [x] Add direct follow-up deep links for specific jobs
|
||||||
- [x] Add attachment selection controls for AI-assisted job drafting tabs
|
- [x] Add attachment selection controls for AI-assisted job drafting tabs
|
||||||
|
- [x] Add a compact strategy snapshot on the job overview
|
||||||
- [ ] Perform final UX clarity pass across major screens
|
- [ ] Perform final UX clarity pass across major screens
|
||||||
- [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages
|
- [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
InputLabel,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -37,6 +42,8 @@ interface AttachmentItem {
|
|||||||
uploadDate: string;
|
uploadDate: string;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
|
purpose?: string | null;
|
||||||
|
useForAi: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtSize(n: number) {
|
function fmtSize(n: number) {
|
||||||
@@ -56,10 +63,23 @@ function isPdfType(t: string) {
|
|||||||
|
|
||||||
function guessKind(fileName: string): string {
|
function guessKind(fileName: string): string {
|
||||||
const n = (fileName || "").toLowerCase();
|
const n = (fileName || "").toLowerCase();
|
||||||
if (n.includes("cover")) return "Cover letter";
|
if (n.includes("cover")) return "cover-letter";
|
||||||
if (n.includes("resume") || n.includes("résumé") || n.includes(" cv") || n.endsWith("cv.pdf")) return "Resume";
|
if (n.includes("resume") || n.includes("résumé") || n.includes(" cv") || n.endsWith("cv.pdf")) return "resume";
|
||||||
if (n.includes("portfolio")) return "Portfolio";
|
if (n.includes("portfolio")) return "portfolio";
|
||||||
return "Attachment";
|
if (n.includes("case") || n.includes("sample")) return "case-study";
|
||||||
|
if (n.includes("cert")) return "certificate";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function purposeLabel(purpose: string | null | undefined, t: (key: any) => string) {
|
||||||
|
switch ((purpose || "").trim().toLowerCase()) {
|
||||||
|
case "resume": return t("attachmentsPurposeResume");
|
||||||
|
case "cover-letter": return t("attachmentsPurposeCoverLetter");
|
||||||
|
case "portfolio": return t("attachmentsPurposePortfolio");
|
||||||
|
case "case-study": return t("attachmentsPurposeCaseStudy");
|
||||||
|
case "certificate": return t("attachmentsPurposeCertificate");
|
||||||
|
default: return t("attachmentsPurposeOther");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Attachments({ jobId }: { jobId: number }) {
|
export default function Attachments({ jobId }: { jobId: number }) {
|
||||||
@@ -105,7 +125,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
await api.post("/attachments", data, {
|
await api.post("/attachments", data, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
toast(files.length === 1 ? "File uploaded." : `${files.length} files uploaded.`, "success");
|
toast(files.length === 1 ? t("attachmentsUploadedSingle") : t("attachmentsUploadedMany", { count: files.length }), "success");
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, t("attachmentsUploadFailed")), "error");
|
toast(getApiErrorMessage(error, t("attachmentsUploadFailed")), "error");
|
||||||
@@ -117,7 +137,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rename = async (a: AttachmentItem) => {
|
const rename = async (a: AttachmentItem) => {
|
||||||
const next = await promptForValue("Rename attachment to:", a.fileName, { title: "Rename attachment", confirmLabel: "Rename" });
|
const next = await promptForValue(t("attachmentsRenamePrompt"), a.fileName, { title: t("attachmentsRenameTitle"), confirmLabel: t("attachmentsRename") });
|
||||||
if (!next || next.trim() === a.fileName) return;
|
if (!next || next.trim() === a.fileName) return;
|
||||||
try {
|
try {
|
||||||
await api.patch(`/attachments/${a.id}`, { fileName: next.trim() });
|
await api.patch(`/attachments/${a.id}`, { fileName: next.trim() });
|
||||||
@@ -128,8 +148,18 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateMetadata = async (a: AttachmentItem, patch: Partial<Pick<AttachmentItem, "purpose" | "useForAi">>) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/attachments/${a.id}`, { purpose: patch.purpose ?? a.purpose ?? guessKind(a.fileName), useForAi: patch.useForAi ?? a.useForAi });
|
||||||
|
setItems((current) => current.map((item) => item.id === a.id ? { ...item, ...patch } : item));
|
||||||
|
toast(t("attachmentsUpdated"), "success");
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, t("attachmentsUpdateFailed")), "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async (a: AttachmentItem) => {
|
const remove = async (a: AttachmentItem) => {
|
||||||
if (!(await confirmAction(`Delete attachment "${a.fileName}"?`, { title: "Delete attachment", confirmLabel: "Delete", destructive: true }))) return;
|
if (!(await confirmAction(t("attachmentsDeleteConfirm", { name: a.fileName }), { title: t("attachmentsDeleteTitle"), confirmLabel: t("attachmentsDelete"), destructive: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/attachments/${a.id}`);
|
await api.delete(`/attachments/${a.id}`);
|
||||||
toast(t("attachmentsDeleted"), "success");
|
toast(t("attachmentsDeleted"), "success");
|
||||||
@@ -245,14 +275,15 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
<TableCell sx={{ width: 140 }}>{t("attachmentsKind")}</TableCell>
|
<TableCell sx={{ width: 140 }}>{t("attachmentsKind")}</TableCell>
|
||||||
<TableCell sx={{ width: 120 }}>{t("attachmentsType")}</TableCell>
|
<TableCell sx={{ width: 120 }}>{t("attachmentsType")}</TableCell>
|
||||||
<TableCell sx={{ width: 90 }}>{t("attachmentsSize")}</TableCell>
|
<TableCell sx={{ width: 90 }}>{t("attachmentsSize")}</TableCell>
|
||||||
<TableCell sx={{ width: 170 }}>{t("attachmentsUploaded")}</TableCell>
|
<TableCell sx={{ width: 160 }}>{t("attachmentsPurpose")}</TableCell>
|
||||||
|
<TableCell sx={{ width: 110 }}>{t("attachmentsAiUse")}</TableCell>
|
||||||
<TableCell sx={{ width: 170 }}>{t("attachmentsActions")}</TableCell>
|
<TableCell sx={{ width: 170 }}>{t("attachmentsActions")}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((a) => {
|
{items.map((a) => {
|
||||||
const canPreview = isImageType(a.fileType) || isPdfType(a.fileType);
|
const canPreview = isImageType(a.fileType) || isPdfType(a.fileType);
|
||||||
const kind = guessKind(a.fileName);
|
const kind = purposeLabel(a.purpose || guessKind(a.fileName), t);
|
||||||
return (
|
return (
|
||||||
<TableRow key={a.id} hover>
|
<TableRow key={a.id} hover>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -264,6 +295,25 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
<TableCell sx={{ color: "text.secondary" }}>{a.fileType ? a.fileType.replace("application/", "") : ""}</TableCell>
|
<TableCell sx={{ color: "text.secondary" }}>{a.fileType ? a.fileType.replace("application/", "") : ""}</TableCell>
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{a.fileSize ? fmtSize(a.fileSize) : ""}</TableCell>
|
<TableCell sx={{ color: "text.secondary" }}>{a.fileSize ? fmtSize(a.fileSize) : ""}</TableCell>
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
|
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormControl size="small" fullWidth>
|
||||||
|
<InputLabel>{t("attachmentsPurpose")}</InputLabel>
|
||||||
|
<Select value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
|
||||||
|
<MenuItem value="resume">{t("attachmentsPurposeResume")}</MenuItem>
|
||||||
|
<MenuItem value="cover-letter">{t("attachmentsPurposeCoverLetter")}</MenuItem>
|
||||||
|
<MenuItem value="portfolio">{t("attachmentsPurposePortfolio")}</MenuItem>
|
||||||
|
<MenuItem value="case-study">{t("attachmentsPurposeCaseStudy")}</MenuItem>
|
||||||
|
<MenuItem value="certificate">{t("attachmentsPurposeCertificate")}</MenuItem>
|
||||||
|
<MenuItem value="other">{t("attachmentsPurposeOther")}</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
<Switch size="small" checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{a.useForAi ? t("attachmentsAiEnabled") : t("attachmentsAiDisabled")}</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: "flex", gap: 0.5, flex: "0 0 auto" }}>
|
<Box sx={{ display: "flex", gap: 0.5, flex: "0 0 auto" }}>
|
||||||
{canPreview ? (
|
{canPreview ? (
|
||||||
@@ -288,7 +338,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6}>
|
<TableCell colSpan={8}>
|
||||||
<Alert severity="info" variant="outlined">
|
<Alert severity="info" variant="outlined">
|
||||||
{t("attachmentsEmpty")}
|
{t("attachmentsEmpty")}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type AttachmentItem = {
|
|||||||
uploadDate: string;
|
uploadDate: string;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
|
purpose?: string | null;
|
||||||
|
useForAi: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FollowUpDraft = {
|
type FollowUpDraft = {
|
||||||
@@ -97,6 +99,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
||||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||||
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
||||||
|
const [loadingStrategySnapshot, setLoadingStrategySnapshot] = useState(false);
|
||||||
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
|
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
|
||||||
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
|
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
|
||||||
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
||||||
@@ -137,7 +140,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
api.get<AttachmentItem[]>(`/attachments/${jobId}`).then((r) => {
|
api.get<AttachmentItem[]>(`/attachments/${jobId}`).then((r) => {
|
||||||
const items = Array.isArray(r.data) ? r.data : [];
|
const items = Array.isArray(r.data) ? r.data : [];
|
||||||
setJobAttachments(items);
|
setJobAttachments(items);
|
||||||
setSelectedAttachmentIds(items.slice(0, 3).map((item) => item.id));
|
const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id);
|
||||||
|
setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id));
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setJobAttachments([]);
|
setJobAttachments([]);
|
||||||
setSelectedAttachmentIds([]);
|
setSelectedAttachmentIds([]);
|
||||||
@@ -273,6 +277,40 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
|
|
||||||
{tab === 0 && (
|
{tab === 0 && (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||||
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Typography variant="overline">{t("jobDetailsStrategySnapshot")}</Typography>
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingStrategySnapshot} onClick={async () => {
|
||||||
|
if (!jobId) return;
|
||||||
|
setLoadingStrategySnapshot(true);
|
||||||
|
try {
|
||||||
|
const [fitRes, focusRes] = await Promise.all([
|
||||||
|
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||||
|
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||||
|
]);
|
||||||
|
setCandidateFit(fitRes.data);
|
||||||
|
setFocusPlan(focusRes.data);
|
||||||
|
} catch {
|
||||||
|
toast(t("jobDetailsStrategySnapshotFailed"), "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingStrategySnapshot(false);
|
||||||
|
}
|
||||||
|
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : t("jobDetailsGenerateStrategySnapshot")}</Button>
|
||||||
|
</Box>
|
||||||
|
{candidateFit || focusPlan ? (
|
||||||
|
<Box sx={{ gridColumn: "1 / -1", p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||||
|
{candidateFit ? <Chip size="small" color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} /> : null}
|
||||||
|
{candidateFit?.fitLevel ? <Chip size="small" variant="outlined" label={candidateFit.fitLevel} /> : null}
|
||||||
|
</Box>
|
||||||
|
{focusPlan?.strategicSummary ? <Typography sx={{ whiteSpace: "pre-wrap", mb: 1 }}>{focusPlan.strategicSummary}</Typography> : null}
|
||||||
|
{candidateFit?.matchSummary ? <Typography sx={{ color: "text.secondary", whiteSpace: "pre-wrap", mb: 1.5 }}>{candidateFit.matchSummary}</Typography> : null}
|
||||||
|
{focusPlan?.immediatePriorities?.length ? <ListCard title={t("jobDetailsImmediatePriorities")} items={focusPlan.immediatePriorities.slice(0, 3)} /> : null}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>{t("jobDetailsStrategySnapshotEmpty")}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
||||||
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
|
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
|
||||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>
|
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>
|
||||||
|
|||||||
@@ -426,15 +426,33 @@ export const translations = {
|
|||||||
attachmentsType: "Type",
|
attachmentsType: "Type",
|
||||||
attachmentsSize: "Size",
|
attachmentsSize: "Size",
|
||||||
attachmentsUploaded: "Uploaded",
|
attachmentsUploaded: "Uploaded",
|
||||||
|
attachmentsPurpose: "Purpose",
|
||||||
|
attachmentsAiUse: "Use for AI",
|
||||||
attachmentsActions: "Actions",
|
attachmentsActions: "Actions",
|
||||||
attachmentsPreview: "Preview",
|
attachmentsPreview: "Preview",
|
||||||
attachmentsDownload: "Download",
|
attachmentsDownload: "Download",
|
||||||
attachmentsRename: "Rename",
|
attachmentsRename: "Rename",
|
||||||
attachmentsDelete: "Delete",
|
attachmentsDelete: "Delete",
|
||||||
|
attachmentsPurposeResume: "Resume",
|
||||||
|
attachmentsPurposeCoverLetter: "Cover letter",
|
||||||
|
attachmentsPurposePortfolio: "Portfolio",
|
||||||
|
attachmentsPurposeCaseStudy: "Case study",
|
||||||
|
attachmentsPurposeCertificate: "Certificate",
|
||||||
|
attachmentsPurposeOther: "Other",
|
||||||
|
attachmentsRenamePrompt: "Rename attachment to:",
|
||||||
|
attachmentsRenameTitle: "Rename attachment",
|
||||||
|
attachmentsDeleteTitle: "Delete attachment",
|
||||||
|
attachmentsDeleteConfirm: "Delete attachment \"{name}\"?",
|
||||||
|
attachmentsUploadedSingle: "File uploaded.",
|
||||||
|
attachmentsUploadedMany: "{count} files uploaded.",
|
||||||
|
attachmentsAiEnabled: "Included",
|
||||||
|
attachmentsAiDisabled: "Ignored",
|
||||||
attachmentsEmpty: "No attachments yet. Upload a resume, cover letter, or portfolio to keep everything tied to this job.",
|
attachmentsEmpty: "No attachments yet. Upload a resume, cover letter, or portfolio to keep everything tied to this job.",
|
||||||
attachmentsPreviewTitle: "Preview: {name}",
|
attachmentsPreviewTitle: "Preview: {name}",
|
||||||
attachmentsNoInlinePreview: "No inline preview for this file type.",
|
attachmentsNoInlinePreview: "No inline preview for this file type.",
|
||||||
attachmentsUploadFailed: "Upload failed.",
|
attachmentsUploadFailed: "Upload failed.",
|
||||||
|
attachmentsUpdated: "Attachment updated.",
|
||||||
|
attachmentsUpdateFailed: "Failed to update attachment.",
|
||||||
attachmentsRenamed: "Renamed.",
|
attachmentsRenamed: "Renamed.",
|
||||||
attachmentsRenameFailed: "Rename failed.",
|
attachmentsRenameFailed: "Rename failed.",
|
||||||
attachmentsDeleted: "Deleted attachment.",
|
attachmentsDeleted: "Deleted attachment.",
|
||||||
@@ -734,6 +752,10 @@ export const translations = {
|
|||||||
jobDetailsFollowUpSent: "Follow-up sent and logged.",
|
jobDetailsFollowUpSent: "Follow-up sent and logged.",
|
||||||
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
|
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
|
||||||
jobDetailsHowYouMatch: "How you match",
|
jobDetailsHowYouMatch: "How you match",
|
||||||
|
jobDetailsStrategySnapshot: "Strategy snapshot",
|
||||||
|
jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot",
|
||||||
|
jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.",
|
||||||
|
jobDetailsStrategySnapshotFailed: "Failed to generate strategy snapshot.",
|
||||||
jobDetailsMatchPercent: "{count}% match",
|
jobDetailsMatchPercent: "{count}% match",
|
||||||
jobDetailsTailoredPitch: "Tailored pitch",
|
jobDetailsTailoredPitch: "Tailored pitch",
|
||||||
jobDetailsStrongMatches: "Strong matches",
|
jobDetailsStrongMatches: "Strong matches",
|
||||||
@@ -1204,15 +1226,33 @@ export const translations = {
|
|||||||
attachmentsType: "Filtype",
|
attachmentsType: "Filtype",
|
||||||
attachmentsSize: "Størrelse",
|
attachmentsSize: "Størrelse",
|
||||||
attachmentsUploaded: "Lastet opp",
|
attachmentsUploaded: "Lastet opp",
|
||||||
|
attachmentsPurpose: "Formål",
|
||||||
|
attachmentsAiUse: "Bruk for AI",
|
||||||
attachmentsActions: "Handlinger",
|
attachmentsActions: "Handlinger",
|
||||||
attachmentsPreview: "Forhåndsvis",
|
attachmentsPreview: "Forhåndsvis",
|
||||||
attachmentsDownload: "Last ned",
|
attachmentsDownload: "Last ned",
|
||||||
attachmentsRename: "Gi nytt navn",
|
attachmentsRename: "Gi nytt navn",
|
||||||
attachmentsDelete: "Slett",
|
attachmentsDelete: "Slett",
|
||||||
|
attachmentsPurposeResume: "CV",
|
||||||
|
attachmentsPurposeCoverLetter: "Søknadsbrev",
|
||||||
|
attachmentsPurposePortfolio: "Portefølje",
|
||||||
|
attachmentsPurposeCaseStudy: "Case-studie",
|
||||||
|
attachmentsPurposeCertificate: "Sertifikat",
|
||||||
|
attachmentsPurposeOther: "Annet",
|
||||||
|
attachmentsRenamePrompt: "Gi vedlegget nytt navn:",
|
||||||
|
attachmentsRenameTitle: "Gi nytt navn til vedlegg",
|
||||||
|
attachmentsDeleteTitle: "Slett vedlegg",
|
||||||
|
attachmentsDeleteConfirm: "Slette vedlegget \"{name}\"?",
|
||||||
|
attachmentsUploadedSingle: "Fil lastet opp.",
|
||||||
|
attachmentsUploadedMany: "{count} filer lastet opp.",
|
||||||
|
attachmentsAiEnabled: "Inkludert",
|
||||||
|
attachmentsAiDisabled: "Ignorert",
|
||||||
attachmentsEmpty: "Ingen vedlegg ennå. Last opp en CV, et søknadsbrev eller en portefølje for å knytte alt til denne jobben.",
|
attachmentsEmpty: "Ingen vedlegg ennå. Last opp en CV, et søknadsbrev eller en portefølje for å knytte alt til denne jobben.",
|
||||||
attachmentsPreviewTitle: "Forhåndsvisning: {name}",
|
attachmentsPreviewTitle: "Forhåndsvisning: {name}",
|
||||||
attachmentsNoInlinePreview: "Ingen innebygd forhåndsvisning for denne filtypen.",
|
attachmentsNoInlinePreview: "Ingen innebygd forhåndsvisning for denne filtypen.",
|
||||||
attachmentsUploadFailed: "Opplasting mislyktes.",
|
attachmentsUploadFailed: "Opplasting mislyktes.",
|
||||||
|
attachmentsUpdated: "Vedlegg oppdatert.",
|
||||||
|
attachmentsUpdateFailed: "Kunne ikke oppdatere vedlegget.",
|
||||||
attachmentsRenamed: "Gi nytt navn fullført.",
|
attachmentsRenamed: "Gi nytt navn fullført.",
|
||||||
attachmentsRenameFailed: "Kunne ikke gi nytt navn.",
|
attachmentsRenameFailed: "Kunne ikke gi nytt navn.",
|
||||||
attachmentsDeleted: "Vedlegg slettet.",
|
attachmentsDeleted: "Vedlegg slettet.",
|
||||||
@@ -1512,6 +1552,10 @@ export const translations = {
|
|||||||
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
|
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
|
||||||
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
|
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
|
||||||
jobDetailsHowYouMatch: "Slik matcher du",
|
jobDetailsHowYouMatch: "Slik matcher du",
|
||||||
|
jobDetailsStrategySnapshot: "Strategioversikt",
|
||||||
|
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
|
||||||
|
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
|
||||||
|
jobDetailsStrategySnapshotFailed: "Kunne ikke generere strategioversikt.",
|
||||||
jobDetailsMatchPercent: "{count}% match",
|
jobDetailsMatchPercent: "{count}% match",
|
||||||
jobDetailsTailoredPitch: "Tilpasset pitch",
|
jobDetailsTailoredPitch: "Tilpasset pitch",
|
||||||
jobDetailsStrongMatches: "Sterke matcher",
|
jobDetailsStrongMatches: "Sterke matcher",
|
||||||
|
|||||||
Reference in New Issue
Block a user