Feat/job page relevant actions (#197)

* initial commit

* ui better-ish

* ci fix
This commit is contained in:
Shaheer Sarfaraz 2026-02-19 18:59:47 +00:00 committed by GitHub
parent aefb6ca78b
commit 8b71bef5cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -10,9 +10,18 @@ import confetti from "canvas-confetti";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
ClipboardList,
Copy,
DollarSign,
Edit2,
ExternalLink,
FileText,
MoreHorizontal,
PlusCircle,
RefreshCcw,
Sparkles,
XCircle,
} from "lucide-react";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
@ -20,10 +29,22 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTimestamp } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
copyTextToClipboard,
formatJobForWebhook,
formatTimestamp,
} from "@/lib/utils";
import * as api from "../api";
import { ConfirmDelete } from "../components/ConfirmDelete";
import { GhostwriterDrawer } from "../components/ghostwriter/GhostwriterDrawer";
import { JobDetailsEditDrawer } from "../components/JobDetailsEditDrawer";
import { JobHeader } from "../components/JobHeader";
import {
type LogEventFormValues,
@ -40,6 +61,8 @@ export const JobPage: React.FC = () => {
const [isLoading, setIsLoading] = React.useState(true);
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
const [isEditDetailsOpen, setIsEditDetailsOpen] = React.useState(false);
const [activeAction, setActiveAction] = React.useState<string | null>(null);
const [eventToDelete, setEventToDelete] = React.useState<string | null>(null);
const [editingEvent, setEditingEvent] = React.useState<StageEvent | null>(
null,
@ -184,13 +207,102 @@ export const JobPage: React.FC = () => {
setIsLogModalOpen(true);
};
const runAction = React.useCallback(
async (actionKey: string, task: () => Promise<void>) => {
if (!job) return;
try {
setActiveAction(actionKey);
await task();
await loadData();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to run action";
toast.error(message);
} finally {
setActiveAction(null);
}
},
[job, loadData],
);
const handleMarkApplied = async () => {
await runAction("mark-applied", async () => {
if (!job) return;
await api.markAsApplied(job.id);
toast.success("Marked as applied");
});
};
const handleMoveToInProgress = async () => {
await runAction("move-in-progress", async () => {
if (!job) return;
await api.updateJob(job.id, { status: "in_progress" });
toast.success("Moved to in progress");
});
};
const handleSkip = async () => {
await runAction("skip", async () => {
if (!job) return;
await api.skipJob(job.id);
toast.message("Job skipped");
});
};
const handleRescore = async () => {
await runAction("rescore", async () => {
if (!job) return;
await api.rescoreJob(job.id);
toast.success("Match recalculated");
});
};
const handleRegeneratePdf = async () => {
await runAction("regenerate-pdf", async () => {
if (!job) return;
await api.generateJobPdf(job.id);
toast.success("Resume PDF generated");
});
};
const handleCheckSponsor = async () => {
await runAction("check-sponsor", async () => {
if (!job) return;
await api.checkSponsor(job.id);
toast.success("Sponsor check completed");
});
};
const handleCopyJobInfo = async () => {
if (!job) return;
try {
await copyTextToClipboard(formatJobForWebhook(job));
toast.success("Copied job info", {
description: "Webhook payload copied to clipboard.",
});
} catch {
toast.error("Could not copy job info");
}
};
const currentStage = job
? (events.at(-1)?.toStage ??
(job.status === "applied" || job.status === "in_progress"
? "applied"
: null))
: null;
const isClosedStage = currentStage === "closed";
const canTrackStages = job?.status === "in_progress";
const canLogEvents = canTrackStages && !isClosedStage;
const jobLink = job ? job.applicationLink || job.jobUrl : null;
const pdfHref = job?.pdfPath
? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`
: null;
const isBusy = activeAction !== null;
const isDiscovered = job?.status === "discovered";
const isReady = job?.status === "ready";
const isApplied = job?.status === "applied";
const isInProgress = job?.status === "in_progress";
if (!id) {
return null;
@ -203,23 +315,13 @@ export const JobPage: React.FC = () => {
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-3">
<Button
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsLogModalOpen(true)}
disabled={!job || !canTrackStages}
>
<PlusCircle className="mr-2 h-4 w-4" />
Log Event
</Button>
</div>
</div>
{job ? (
<JobHeader
job={job}
className="rounded-lg border border-border/40 bg-muted/5 p-4"
onCheckSponsor={handleCheckSponsor}
/>
) : (
<div className="rounded-lg border border-dashed border-border/40 p-6 text-sm text-muted-foreground">
@ -227,6 +329,175 @@ export const JobPage: React.FC = () => {
</div>
)}
{job && (
<div className="rounded-xl border border-border/60 bg-card/80 p-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-card/65">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{jobLink && (
<Button
asChild
size="sm"
className="h-9 border border-orange-400/50 bg-orange-500/20 text-orange-100 hover:bg-orange-500/30"
>
<a href={jobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open Job Listing
</a>
</Button>
)}
{isReady && (
<>
<Button
size="sm"
variant="outline"
className="h-9 border-orange-400/50 bg-orange-500/10 text-orange-100 hover:bg-orange-500/20"
onClick={() => void handleMarkApplied()}
disabled={isBusy}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
Mark Applied
</Button>
<Button
size="sm"
variant="outline"
className="h-9 border-border/60 bg-background/30"
onClick={() => void handleSkip()}
disabled={isBusy}
>
<XCircle className="mr-1.5 h-3.5 w-3.5" />
Skip Job
</Button>
</>
)}
{isDiscovered && (
<>
<Button
size="sm"
className="h-9 border border-orange-400/50 bg-orange-500/20 text-orange-100 hover:bg-orange-500/30"
onClick={() => navigate(`/jobs/discovered/${job.id}`)}
disabled={isBusy}
>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Start Tailoring
</Button>
<Button
size="sm"
variant="outline"
className="h-9 border-border/60 bg-background/30"
onClick={() => void handleSkip()}
disabled={isBusy}
>
<XCircle className="mr-1.5 h-3.5 w-3.5" />
Skip Job
</Button>
</>
)}
{isApplied && (
<Button
size="sm"
className="h-9 border border-orange-400/50 bg-orange-500/20 text-orange-100 hover:bg-orange-500/30"
onClick={() => void handleMoveToInProgress()}
disabled={isBusy}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
Move to In Progress
</Button>
)}
{isInProgress && (
<Button
size="sm"
className="h-9 border border-orange-400/50 bg-orange-500/20 text-orange-100 hover:bg-orange-500/30"
onClick={() => setIsLogModalOpen(true)}
disabled={!canLogEvents || isBusy}
>
<PlusCircle className="mr-2 h-4 w-4" />
Log Event
</Button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{isReady && (
<Button
size="sm"
variant="outline"
className="h-9 border-border/60 bg-background/30"
onClick={() => navigate(`/jobs/ready/${job.id}`)}
disabled={isBusy}
>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Edit Tailoring
</Button>
)}
{pdfHref && (
<Button
asChild
size="sm"
variant="outline"
className="h-9 border-border/60 bg-background/30"
>
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<FileText className="mr-1.5 h-3.5 w-3.5" />
View PDF
</a>
</Button>
)}
{isReady && (
<Button
size="sm"
variant="outline"
className="h-9 border-border/60 bg-background/30"
onClick={() => void handleRegeneratePdf()}
disabled={isBusy}
>
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
Regenerate PDF
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-9 w-9 border-border/60 bg-background/30"
aria-label="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setIsEditDetailsOpen(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleCopyJobInfo()}>
<Copy className="mr-2 h-4 w-4" />
Copy job info
</DropdownMenuItem>
{(isReady || isDiscovered) && (
<DropdownMenuItem onSelect={() => void handleRescore()}>
<RefreshCcw className="mr-2 h-4 w-4" />
Recalculate match
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleCheckSponsor()}>
Check sponsorship status
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)}
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="border-border/50">
<CardHeader>
@ -263,10 +534,15 @@ export const JobPage: React.FC = () => {
Move this job to In Progress to track application stages.
</div>
)}
{canTrackStages && isClosedStage && (
<div className="mb-4 rounded-md border border-dashed border-border/60 p-3 text-sm text-muted-foreground">
This application is closed. Stage logging is disabled.
</div>
)}
<JobTimeline
events={events}
onEdit={canTrackStages ? handleEditEvent : undefined}
onDelete={canTrackStages ? confirmDeleteEvent : undefined}
onEdit={canLogEvents ? handleEditEvent : undefined}
onDelete={canLogEvents ? confirmDeleteEvent : undefined}
/>
</CardContent>
</Card>
@ -373,6 +649,13 @@ export const JobPage: React.FC = () => {
}}
onConfirm={handleDeleteEvent}
/>
<JobDetailsEditDrawer
open={isEditDetailsOpen}
onOpenChange={setIsEditDetailsOpen}
job={job}
onJobUpdated={loadData}
/>
</main>
);
};