Add confirmation for deletion
This commit is contained in:
@@ -722,6 +722,88 @@ namespace JobTrackerApi.Controllers
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/refresh-ai")]
|
||||||
|
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications
|
||||||
|
.Include(j => j.Company)
|
||||||
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var sourceText = string.Join("
|
||||||
|
|
||||||
|
", new[] { job.Description, job.TranslatedDescription, job.Notes }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceText))
|
||||||
|
{
|
||||||
|
return BadRequest("This job does not have enough description or notes to generate a summary and skills.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags = SkillTagger.Detect(sourceText)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
job.Tags = tags.Count == 0 ? null : JsonSerializer.Serialize(tags);
|
||||||
|
|
||||||
|
var shortSummary = await _summarizer.SummarizeAsync(sourceText, 160, 60);
|
||||||
|
job.ShortSummary = string.IsNullOrWhiteSpace(shortSummary) ? job.ShortSummary : shortSummary;
|
||||||
|
|
||||||
|
_db.JobEvents.Add(new JobEvent
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
Type = "AiRefreshed",
|
||||||
|
Note = "Summary and tags were manually refreshed.",
|
||||||
|
At = DateTime.Now
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var settings = await RulesEngine.GetSettings(_db, cancellationToken);
|
||||||
|
var lastMsg = await _db.Correspondences
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.JobApplicationId == id)
|
||||||
|
.OrderByDescending(c => c.Date)
|
||||||
|
.Select(c => (DateTime?)c.Date)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
var followUp = RulesEngine.Evaluate(settings, job, DateTime.Now, lastMsg);
|
||||||
|
|
||||||
|
return Ok(new JobApplicationDto(
|
||||||
|
Id: job.Id,
|
||||||
|
CompanyId: job.CompanyId,
|
||||||
|
Company: job.Company,
|
||||||
|
JobTitle: job.JobTitle,
|
||||||
|
Status: job.Status,
|
||||||
|
DateApplied: job.DateApplied,
|
||||||
|
ResponseReceived: job.ResponseReceived,
|
||||||
|
ResponseDate: job.ResponseDate,
|
||||||
|
Notes: job.Notes,
|
||||||
|
CoverLetterText: job.CoverLetterText,
|
||||||
|
JobUrl: job.JobUrl,
|
||||||
|
Description: job.Description,
|
||||||
|
TranslatedDescription: job.TranslatedDescription,
|
||||||
|
DescriptionLanguage: job.DescriptionLanguage,
|
||||||
|
Tags: job.Tags,
|
||||||
|
Deadline: job.Deadline,
|
||||||
|
Location: job.Location,
|
||||||
|
Salary: job.Salary,
|
||||||
|
NextAction: job.NextAction,
|
||||||
|
FollowUpAt: job.FollowUpAt,
|
||||||
|
FeedbackRequestedAt: job.FeedbackRequestedAt,
|
||||||
|
HasResume: job.HasResume,
|
||||||
|
HasCoverLetter: job.HasCoverLetter,
|
||||||
|
HasPortfolio: job.HasPortfolio,
|
||||||
|
HasOtherAttachment: job.HasOtherAttachment,
|
||||||
|
IsDeleted: job.IsDeleted,
|
||||||
|
DeletedAt: job.DeletedAt,
|
||||||
|
DaysSince: job.DaysSince,
|
||||||
|
NeedsFollowUp: followUp.NeedsFollowUp,
|
||||||
|
FollowUpReason: followUp.Reason,
|
||||||
|
ShortSummary: job.ShortSummary,
|
||||||
|
FullSummary: null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> SoftDelete([FromRoute] int id, CancellationToken cancellationToken)
|
public async Task<IActionResult> SoftDelete([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
|
|
||||||
const deleteMessage = async (messageId: number) => {
|
const deleteMessage = async (messageId: number) => {
|
||||||
|
if (!window.confirm("Remove this correspondence message?")) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/correspondence/${messageId}`);
|
await api.delete(`/correspondence/${messageId}`);
|
||||||
await load();
|
await load();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { JobApplication } from "../types";
|
import { JobApplication } from "../types";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
|
||||||
import Correspondence from "./Correspondence";
|
import Correspondence from "./Correspondence";
|
||||||
import Attachments from "./Attachments";
|
import Attachments from "./Attachments";
|
||||||
@@ -50,6 +51,7 @@ function statusChipColor(status: string): "default" | "primary" | "warning" | "e
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
const [job, setJob] = useState<JobApplication | null>(null);
|
const [job, setJob] = useState<JobApplication | null>(null);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]);
|
const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]);
|
||||||
@@ -57,6 +59,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
|
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
|
||||||
const [loadingDraft, setLoadingDraft] = useState(false);
|
const [loadingDraft, setLoadingDraft] = useState(false);
|
||||||
const [sendingDraft, setSendingDraft] = useState(false);
|
const [sendingDraft, setSendingDraft] = useState(false);
|
||||||
|
const [refreshingAi, setRefreshingAi] = useState(false);
|
||||||
const [draftRecipient, setDraftRecipient] = useState("");
|
const [draftRecipient, setDraftRecipient] = useState("");
|
||||||
const [draftSubject, setDraftSubject] = useState("");
|
const [draftSubject, setDraftSubject] = useState("");
|
||||||
const [draftBody, setDraftBody] = useState("");
|
const [draftBody, setDraftBody] = useState("");
|
||||||
@@ -182,12 +185,33 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job.translatedDescription}</Typography>
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job.translatedDescription}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
{job?.fullSummary || job?.shortSummary ? (
|
|
||||||
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
||||||
<Typography variant="overline">Summary</Typography>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}>
|
||||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.fullSummary ?? job?.shortSummary}</Typography>
|
<Typography variant="overline">Summary and skills</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={refreshingAi}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!jobId) return;
|
||||||
|
if (!window.confirm("Overwrite the current summary and skills with a freshly generated version?")) return;
|
||||||
|
setRefreshingAi(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<JobApplication>(`/jobapplications/${jobId}/refresh-ai`);
|
||||||
|
setJob(res.data);
|
||||||
|
toast("Summary and skills refreshed.", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.response?.data || "Failed to refresh summary and skills.", "error");
|
||||||
|
} finally {
|
||||||
|
setRefreshingAi(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{refreshingAi ? "Refreshing..." : "Refresh summary and skills"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.fullSummary ?? job?.shortSummary ?? "No summary yet."}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
<Typography variant="overline">Notes</Typography>
|
<Typography variant="overline">Notes</Typography>
|
||||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography>
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography>
|
||||||
|
|||||||
@@ -204,10 +204,20 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id));
|
setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const softDelete = async (id: number) => {
|
const confirmDelete = (jobsToDelete: JobApplication[]) => {
|
||||||
|
if (jobsToDelete.length === 0) return false;
|
||||||
|
if (jobsToDelete.length === 1) {
|
||||||
|
const job = jobsToDelete[0];
|
||||||
|
return window.confirm(`Move "${job.jobTitle}" at ${job.company?.name ?? "this company"} to trash?`);
|
||||||
|
}
|
||||||
|
return window.confirm(`Move ${jobsToDelete.length} selected jobs to trash?`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const softDelete = async (job: JobApplication) => {
|
||||||
|
if (!confirmDelete([job])) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/jobapplications/${id}`);
|
await api.delete(`/jobapplications/${job.id}`);
|
||||||
toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(id); } });
|
toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(job.id); } });
|
||||||
setReloadToken((t) => t + 1);
|
setReloadToken((t) => t + 1);
|
||||||
} catch {
|
} catch {
|
||||||
toast("Failed to delete job.", "error");
|
toast("Failed to delete job.", "error");
|
||||||
@@ -236,6 +246,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
|
|
||||||
const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => {
|
const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => {
|
||||||
if (selectedIds.length === 0) return;
|
if (selectedIds.length === 0) return;
|
||||||
|
const selectedJobs = jobs.filter((job) => selectedIds.includes(job.id));
|
||||||
|
if (action === "delete" && !confirmDelete(selectedJobs)) return;
|
||||||
try {
|
try {
|
||||||
await Promise.all(selectedIds.map((id) => {
|
await Promise.all(selectedIds.map((id) => {
|
||||||
if (action === "delete") return api.delete(`/jobapplications/${id}`);
|
if (action === "delete") return api.delete(`/jobapplications/${id}`);
|
||||||
@@ -354,7 +366,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
<Tooltip title="Edit"><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
<Tooltip title="Edit"><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
<Tooltip title="Quick status"><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
<Tooltip title="Quick status"><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
<Tooltip title="Open"><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
<Tooltip title="Open"><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title="Restore"><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title="Soft delete"><IconButton size="small" onClick={() => void softDelete(job.id)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title="Restore"><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title="Soft delete"><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user