Feature: Remove message, Upgrade: pull better job data, add dedicated status section to job applications
This commit is contained in:
@@ -67,5 +67,17 @@ namespace JobTrackerApi.Controllers
|
|||||||
|
|
||||||
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
|
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Delete([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var message = await _db.Correspondences.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||||
|
if (message is null) return NotFound();
|
||||||
|
|
||||||
|
_db.Correspondences.Remove(message);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
// Generate and persist a short summary at creation time to avoid repeated model calls.
|
// Generate and persist a short summary at creation time to avoid repeated model calls.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 80, 20);
|
var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 160, 60);
|
||||||
job.ShortSummary = shortSum;
|
job.ShortSummary = shortSum;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -626,7 +626,8 @@ namespace JobTrackerApi.Controllers
|
|||||||
string? CoverLetterText,
|
string? CoverLetterText,
|
||||||
string? JobUrl,
|
string? JobUrl,
|
||||||
DateTime? DateApplied,
|
DateTime? DateApplied,
|
||||||
DateTime? FeedbackRequestedAt
|
DateTime? FeedbackRequestedAt,
|
||||||
|
DateTime? StatusChangedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
@@ -686,7 +687,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
Type = "StatusChanged",
|
Type = "StatusChanged",
|
||||||
OldValue = oldStatus,
|
OldValue = oldStatus,
|
||||||
NewValue = job.Status,
|
NewValue = job.Status,
|
||||||
At = DateTime.Now
|
At = request.StatusChangedAt ?? DateTime.Now
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public sealed class JobEnrichmentHostedService : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var shortSummary = await summarizer.SummarizeAsync(sourceText, 80, 20);
|
var shortSummary = await summarizer.SummarizeAsync(sourceText, 160, 60);
|
||||||
if (!string.IsNullOrWhiteSpace(shortSummary))
|
if (!string.IsNullOrWhiteSpace(shortSummary))
|
||||||
{
|
{
|
||||||
job.ShortSummary = shortSummary;
|
job.ShortSummary = shortSummary;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
@@ -7,29 +7,52 @@ namespace JobTrackerApi.Services.JobImport;
|
|||||||
|
|
||||||
public static class SkillTagger
|
public static class SkillTagger
|
||||||
{
|
{
|
||||||
private static readonly (string Tag, Regex Pattern)[] Patterns =
|
private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns =
|
||||||
{
|
{
|
||||||
("C#", new Regex(@"\bC#\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||||
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||||
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||||
("Docker", new Regex(@"\bDocker\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
("Azure", new Regex(@"\bAzure\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
("AWS", new Regex(@"\bAWS\b|\bAmazon Web Services\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("TypeScript", new Regex(@"\bTypeScript\b|\bTS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
("React", new Regex(@"\bReact\b|\bReact\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("React", new Regex(@"\bReact\b|\bReact\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
("TypeScript", new Regex(@"\bTypeScript\b|\bTS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("Node.js", new Regex(@"\bNode\b|\bNode\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
("SQL", new Regex(@"\bSQL\b|\bPostgreSQL\b|\bMySQL\b|\bSQLite\b|\bMS\s*SQL\b|\bT-?SQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("SQL", new Regex(@"\bSQL\b|\bPostgreSQL\b|\bMySQL\b|\bSQLite\b|\bMS\s*SQL\b|\bT-?SQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
("Kubernetes", new Regex(@"\bKubernetes\b|\bK8s\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
("Docker", new Regex(@"\bDocker\b|\bcontainers?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
|
("Kubernetes", new Regex(@"\bKubernetes\b|\bK8s\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
|
("Azure", new Regex(@"\bAzure\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
|
("AWS", new Regex(@"\bAWS\b|\bAmazon Web Services\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
|
("CI/CD", new Regex(@"\bCI/CD\b|continuous integration|continuous delivery|continuous deployment", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4),
|
||||||
|
("REST APIs", new Regex(@"\bREST\b|RESTful|API development|web services", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4),
|
||||||
|
("GraphQL", new Regex(@"\bGraphQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4),
|
||||||
|
("Testing", new Regex(@"\bunit tests?\b|\bintegration tests?\b|\btesting\b|\bTDD\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4),
|
||||||
|
("Agile", new Regex(@"\bAgile\b|\bScrum\b|\bKanban\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3),
|
||||||
|
("Communication", new Regex(@"communication skills?|communicate effectively|stakeholder management", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3),
|
||||||
|
("Collaboration", new Regex(@"collaborat|cross-functional|team player|work closely with", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3),
|
||||||
|
("Problem Solving", new Regex(@"problem solving|solve complex|analytical|troubleshoot", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3),
|
||||||
|
("Leadership", new Regex(@"leadership|mentor|coaching|leading teams?", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3),
|
||||||
|
("Ownership", new Regex(@"ownership|self-starter|take initiative|proactive", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3),
|
||||||
|
("Adaptability", new Regex(@"adaptable|flexible|fast-paced|ambiguity", RegexOptions.IgnoreCase | RegexOptions.Compiled), 2),
|
||||||
|
("Attention to Detail", new Regex(@"attention to detail|detail-oriented|quality-focused", RegexOptions.IgnoreCase | RegexOptions.Compiled), 2),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string[] Detect(string? description)
|
public static string[] Detect(string? description)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(description)) return Array.Empty<string>();
|
if (string.IsNullOrWhiteSpace(description)) return Array.Empty<string>();
|
||||||
var tags = new List<string>(capacity: 8);
|
|
||||||
foreach (var (tag, pattern) in Patterns)
|
var matches = new List<(string Tag, int Score)>();
|
||||||
|
foreach (var (tag, pattern, weight) in Patterns)
|
||||||
{
|
{
|
||||||
if (pattern.IsMatch(description)) tags.Add(tag);
|
if (pattern.IsMatch(description)) matches.Add((tag, weight));
|
||||||
}
|
}
|
||||||
return tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
|
||||||
|
return matches
|
||||||
|
.GroupBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new { Tag = g.Key, Score = g.Max(x => x.Score) })
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(12)
|
||||||
|
.Select(x => x.Tag)
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
IconButton,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -231,6 +233,17 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const deleteMessage = async (messageId: number) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/correspondence/${messageId}`);
|
||||||
|
await load();
|
||||||
|
toast("Message removed.", "success");
|
||||||
|
} catch {
|
||||||
|
toast("Failed to remove message.", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const importGmailMessage = async (messageId: string) => {
|
const importGmailMessage = async (messageId: string) => {
|
||||||
try {
|
try {
|
||||||
setImportingMessageId(messageId);
|
setImportingMessageId(messageId);
|
||||||
@@ -303,11 +316,16 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
{m.content}
|
{m.content}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ display: "block", mt: 0.75, color: "text.secondary" }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{isMe ? "Me" : "Company"}
|
{isMe ? "Me" : "Company"}
|
||||||
{m.channel ? ` - ${m.channel}` : ""}
|
{m.channel ? ` - ${m.channel}` : ""}
|
||||||
{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
@@ -56,6 +58,8 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
const [company, setCompany] = useState<Company | null>(null);
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
const [jobTitle, setJobTitle] = useState("");
|
const [jobTitle, setJobTitle] = useState("");
|
||||||
const [status, setStatus] = useState("Applied");
|
const [status, setStatus] = useState("Applied");
|
||||||
|
const [initialStatus, setInitialStatus] = useState("Applied");
|
||||||
|
const [statusChangedAt, setStatusChangedAt] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
|
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [salary, setSalary] = useState("");
|
const [salary, setSalary] = useState("");
|
||||||
@@ -86,6 +90,8 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
setCompany(j.company ?? null);
|
setCompany(j.company ?? null);
|
||||||
setJobTitle(j.jobTitle ?? "");
|
setJobTitle(j.jobTitle ?? "");
|
||||||
setStatus(j.status ?? "Applied");
|
setStatus(j.status ?? "Applied");
|
||||||
|
setInitialStatus(j.status ?? "Applied");
|
||||||
|
setStatusChangedAt(new Date().toISOString().slice(0, 10));
|
||||||
setDateApplied(toDateInputValue(j.dateApplied));
|
setDateApplied(toDateInputValue(j.dateApplied));
|
||||||
setLocation(j.location ?? "");
|
setLocation(j.location ?? "");
|
||||||
setSalary(j.salary ?? "");
|
setSalary(j.salary ?? "");
|
||||||
@@ -121,6 +127,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
jobTitle: jobTitle.trim(),
|
jobTitle: jobTitle.trim(),
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
status,
|
status,
|
||||||
|
statusChangedAt: status !== initialStatus ? statusChangedAt || null : null,
|
||||||
responseReceived,
|
responseReceived,
|
||||||
responseDate: responseReceived && responseDate ? responseDate : null,
|
responseDate: responseReceived && responseDate ? responseDate : null,
|
||||||
location: location.trim() || null,
|
location: location.trim() || null,
|
||||||
@@ -154,14 +161,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||||
<DialogTitle>Edit job</DialogTitle>
|
<DialogTitle>Edit job</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Box
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
||||||
sx={{
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
display: "grid",
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Application details</Typography>
|
||||||
gridTemplateColumns: "1fr 1fr",
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||||
gap: 2,
|
|
||||||
mt: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={companies}
|
options={companies}
|
||||||
getOptionLabel={(c) => c.name}
|
getOptionLabel={(c) => c.name}
|
||||||
@@ -169,79 +172,64 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
onChange={(_, v) => setCompany(v)}
|
onChange={(_, v) => setCompany(v)}
|
||||||
renderInput={(params) => <TextField {...params} label="Company" />}
|
renderInput={(params) => <TextField {...params} label="Company" />}
|
||||||
/>
|
/>
|
||||||
|
<TextField label="Job title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||||
<TextField select label="Status" value={status} onChange={(e) => setStatus(e.target.value)}>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<MenuItem key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField label="Job Title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Date Applied"
|
label="Applied on"
|
||||||
type="date"
|
type="date"
|
||||||
value={dateApplied}
|
value={dateApplied}
|
||||||
onChange={(e) => setDateApplied(e.target.value)}
|
onChange={(e) => setDateApplied(e.target.value)}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
|
<TextField label="Job URL" value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
<TextField label="Location" value={location} onChange={(e) => setLocation(e.target.value)} />
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Status update</Typography>
|
||||||
<TextField label="Salary" value={salary} onChange={(e) => setSalary(e.target.value)} />
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 2, mt: 1 }}>
|
||||||
|
<TextField select label="Current status" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
<TextField label="Next Action" value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
label="Follow Up"
|
label="Status changed on"
|
||||||
|
type="date"
|
||||||
|
value={statusChangedAt}
|
||||||
|
onChange={(e) => setStatusChangedAt(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
helperText={status === initialStatus ? "Only used when you change the status." : "This date will be recorded in the timeline."}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />}
|
||||||
|
label="Reply received"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Reply received on"
|
||||||
|
type="date"
|
||||||
|
disabled={!responseReceived}
|
||||||
|
value={responseDate}
|
||||||
|
onChange={(e) => setResponseDate(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
<TextField label="Next action" value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label="Follow up on"
|
||||||
type="date"
|
type="date"
|
||||||
value={followUpAt}
|
value={followUpAt}
|
||||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
<TextField
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
label="Job URL"
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Role details</Typography>
|
||||||
value={jobUrl}
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||||
onChange={(e) => setJobUrl(e.target.value)}
|
<TextField label="Location" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||||
sx={{ gridColumn: "1 / -1" }}
|
<TextField label="Salary" value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Notes"
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
sx={{ gridColumn: "1 / -1" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Description (Original)"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
multiline
|
|
||||||
rows={6}
|
|
||||||
sx={{ gridColumn: "1 / -1" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Translated Description"
|
|
||||||
value={translatedDescription}
|
|
||||||
onChange={(e) => setTranslatedDescription(e.target.value)}
|
|
||||||
multiline
|
|
||||||
rows={6}
|
|
||||||
sx={{ gridColumn: "1 / -1" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Description Language"
|
|
||||||
value={descriptionLanguage}
|
|
||||||
onChange={(e) => setDescriptionLanguage(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Deadline"
|
label="Deadline"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -249,59 +237,31 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
onChange={(e) => setDeadline(e.target.value)}
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
|
<TextField label="Description language" value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
<TagsInput value={tags} onChange={setTags} />
|
<TagsInput value={tags} onChange={setTags} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<TextField label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} sx={{ gridColumn: "1 / -1" }} />
|
||||||
<TextField
|
<TextField label="Description (original)" value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} />
|
||||||
label="Cover Letter"
|
<TextField label="Translated description" value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} />
|
||||||
value={coverLetterText}
|
<TextField label="Cover letter" value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} />
|
||||||
onChange={(e) => setCoverLetterText(e.target.value)}
|
|
||||||
multiline
|
|
||||||
rows={6}
|
|
||||||
sx={{ gridColumn: "1 / -1" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />}
|
|
||||||
label="Response received"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Response Date"
|
|
||||||
type="date"
|
|
||||||
disabled={!responseReceived}
|
|
||||||
value={responseDate}
|
|
||||||
onChange={(e) => setResponseDate(e.target.value)}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", gap: 2, flexWrap: "wrap" }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />}
|
|
||||||
label="Resume"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />}
|
|
||||||
label="Cover letter"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />}
|
|
||||||
label="Portfolio"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />}
|
|
||||||
label="Other attachment"
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Attachments checklist</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1 }}>
|
||||||
|
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label="Resume" />
|
||||||
|
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label="Cover letter" />
|
||||||
|
<FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label="Portfolio" />
|
||||||
|
<FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label="Other attachment" />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button variant="contained" onClick={save} disabled={!canSave}>
|
<Button variant="contained" onClick={save} disabled={!canSave}>Save</Button>
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
+39
-6
@@ -65,6 +65,21 @@ _TECH = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_SOFT = [
|
||||||
|
"communication",
|
||||||
|
"collaboration",
|
||||||
|
"teamwork",
|
||||||
|
"problem solving",
|
||||||
|
"leadership",
|
||||||
|
"mentoring",
|
||||||
|
"ownership",
|
||||||
|
"initiative",
|
||||||
|
"adaptability",
|
||||||
|
"stakeholder management",
|
||||||
|
"detail oriented",
|
||||||
|
]
|
||||||
|
|
||||||
def _strip_html(text: str) -> str:
|
def _strip_html(text: str) -> str:
|
||||||
# Good enough for job descriptions pasted from the web.
|
# Good enough for job descriptions pasted from the web.
|
||||||
text = re.sub(r"<\s*br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
text = re.sub(r"<\s*br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||||
@@ -131,10 +146,14 @@ def _role_focused_excerpt(text: str) -> dict:
|
|||||||
nice = _extract_bullets(nice_lines, max_items=5)
|
nice = _extract_bullets(nice_lines, max_items=5)
|
||||||
|
|
||||||
tech_found = []
|
tech_found = []
|
||||||
|
soft_found = []
|
||||||
low = cleaned.lower()
|
low = cleaned.lower()
|
||||||
for t in _TECH:
|
for t in _TECH:
|
||||||
if t in low:
|
if t in low:
|
||||||
tech_found.append(t)
|
tech_found.append(t)
|
||||||
|
for s in _SOFT:
|
||||||
|
if s in low:
|
||||||
|
soft_found.append(s)
|
||||||
|
|
||||||
# Fallback: pick bullet-like lines anywhere if sections are missing.
|
# Fallback: pick bullet-like lines anywhere if sections are missing.
|
||||||
if not responsibilities and not requirements:
|
if not responsibilities and not requirements:
|
||||||
@@ -160,6 +179,7 @@ def _role_focused_excerpt(text: str) -> dict:
|
|||||||
"requirements": requirements,
|
"requirements": requirements,
|
||||||
"nice": nice,
|
"nice": nice,
|
||||||
"tech": tech_found,
|
"tech": tech_found,
|
||||||
|
"soft": soft_found,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -192,26 +212,39 @@ async def summarize(req: SummarizeRequest):
|
|||||||
|
|
||||||
lines = ["Role summary:", summary]
|
lines = ["Role summary:", summary]
|
||||||
|
|
||||||
|
if info["requirements"]:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("What they need from you:")
|
||||||
|
for x in info["requirements"][:7]:
|
||||||
|
lines.append(f"- {x}")
|
||||||
|
|
||||||
if info["responsibilities"]:
|
if info["responsibilities"]:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Key responsibilities:")
|
lines.append("What you would be doing:")
|
||||||
for x in info["responsibilities"][:6]:
|
for x in info["responsibilities"][:6]:
|
||||||
lines.append(f"- {x}")
|
lines.append(f"- {x}")
|
||||||
|
|
||||||
if info["requirements"]:
|
if info["nice"]:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Key requirements:")
|
lines.append("Nice to have:")
|
||||||
for x in info["requirements"][:6]:
|
for x in info["nice"][:5]:
|
||||||
lines.append(f"- {x}")
|
lines.append(f"- {x}")
|
||||||
|
|
||||||
if info["tech"]:
|
if info["tech"]:
|
||||||
# Keep this short; it's just a hint based on keyword matches.
|
|
||||||
uniq = []
|
uniq = []
|
||||||
for t in info["tech"]:
|
for t in info["tech"]:
|
||||||
if t not in uniq:
|
if t not in uniq:
|
||||||
uniq.append(t)
|
uniq.append(t)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Tech keywords: " + ", ".join(uniq[:14]))
|
lines.append("Relevant hard skills: " + ", ".join(uniq[:14]))
|
||||||
|
|
||||||
|
if info["soft"]:
|
||||||
|
uniq_soft = []
|
||||||
|
for s in info["soft"]:
|
||||||
|
if s not in uniq_soft:
|
||||||
|
uniq_soft.append(s)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8]))
|
||||||
|
|
||||||
out = "\n".join(lines).strip()
|
out = "\n".join(lines).strip()
|
||||||
cache[key] = out
|
cache[key] = out
|
||||||
|
|||||||
Reference in New Issue
Block a user