ready panel
This commit is contained in:
parent
841fb3dec9
commit
84043c6f57
417
orchestrator/src/client/components/ReadyPanel.tsx
Normal file
417
orchestrator/src/client/components/ReadyPanel.tsx
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* ReadyPanel - Optimized "shipping lane" view for Ready jobs.
|
||||||
|
*
|
||||||
|
* Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied.
|
||||||
|
* The PDF is the primary artifact, represented abstractly through an Application Kit summary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronUp,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
RefreshCcw,
|
||||||
|
Undo2,
|
||||||
|
Copy,
|
||||||
|
Edit2,
|
||||||
|
XCircle,
|
||||||
|
Briefcase,
|
||||||
|
Building2,
|
||||||
|
FolderKanban,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||||
|
import * as api from "../api";
|
||||||
|
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||||
|
|
||||||
|
interface ReadyPanelProps {
|
||||||
|
job: Job | null;
|
||||||
|
onJobUpdated: () => void | Promise<void>;
|
||||||
|
onJobMoved: (jobId: string) => void;
|
||||||
|
onEditTailoring: () => void;
|
||||||
|
onEditDescription: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeFilenamePart = (value: string | null | undefined) =>
|
||||||
|
(value || "Unknown").replace(/[^\w\s-]/g, "").replace(/\s+/g, "_");
|
||||||
|
|
||||||
|
export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||||
|
job,
|
||||||
|
onJobUpdated,
|
||||||
|
onJobMoved,
|
||||||
|
onEditTailoring,
|
||||||
|
onEditDescription,
|
||||||
|
}) => {
|
||||||
|
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||||
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||||
|
const [recentlyApplied, setRecentlyApplied] = useState<{
|
||||||
|
jobId: string;
|
||||||
|
jobTitle: string;
|
||||||
|
employer: string;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Load project catalog once
|
||||||
|
useEffect(() => {
|
||||||
|
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compute derived values
|
||||||
|
const pdfHref = job
|
||||||
|
? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`
|
||||||
|
: "#";
|
||||||
|
|
||||||
|
const jobLink = job ? job.applicationLink || job.jobUrl : "#";
|
||||||
|
|
||||||
|
const selectedProjectIds = useMemo(() => {
|
||||||
|
return job?.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||||
|
}, [job?.selectedProjectIds]);
|
||||||
|
|
||||||
|
const selectedProjectNames = useMemo(() => {
|
||||||
|
if (!catalog.length || !selectedProjectIds.length) return [];
|
||||||
|
return selectedProjectIds
|
||||||
|
.map(id => catalog.find(p => p.id === id)?.name)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
}, [catalog, selectedProjectIds]);
|
||||||
|
|
||||||
|
const tailoredSummary = job?.tailoredSummary || null;
|
||||||
|
|
||||||
|
// Handle mark as applied with undo capability
|
||||||
|
const handleMarkApplied = useCallback(async () => {
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsMarkingApplied(true);
|
||||||
|
await api.markAsApplied(job.id);
|
||||||
|
|
||||||
|
// Store for undo
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setRecentlyApplied(null);
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
setRecentlyApplied({
|
||||||
|
jobId: job.id,
|
||||||
|
jobTitle: job.title,
|
||||||
|
employer: job.employer,
|
||||||
|
timeoutId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify parent to move to next job
|
||||||
|
onJobMoved(job.id);
|
||||||
|
await onJobUpdated();
|
||||||
|
|
||||||
|
toast.success("Marked as applied", {
|
||||||
|
description: `${job.title} at ${job.employer}`,
|
||||||
|
action: {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => handleUndoApplied(job.id),
|
||||||
|
},
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to mark as applied";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsMarkingApplied(false);
|
||||||
|
}
|
||||||
|
}, [job, onJobMoved, onJobUpdated]);
|
||||||
|
|
||||||
|
const handleUndoApplied = useCallback(
|
||||||
|
async (jobId: string) => {
|
||||||
|
try {
|
||||||
|
// Revert to ready status
|
||||||
|
await api.updateJob(jobId, { status: "ready" });
|
||||||
|
toast.success("Reverted to Ready");
|
||||||
|
|
||||||
|
if (recentlyApplied?.timeoutId) {
|
||||||
|
clearTimeout(recentlyApplied.timeoutId);
|
||||||
|
}
|
||||||
|
setRecentlyApplied(null);
|
||||||
|
await onJobUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to undo";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onJobUpdated, recentlyApplied],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegenerate = useCallback(async () => {
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRegenerating(true);
|
||||||
|
await api.generateJobPdf(job.id);
|
||||||
|
toast.success("PDF regenerated");
|
||||||
|
await onJobUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}
|
||||||
|
}, [job, onJobUpdated]);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(async () => {
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.skipJob(job.id);
|
||||||
|
toast.message("Job skipped");
|
||||||
|
onJobMoved(job.id);
|
||||||
|
await onJobUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to skip";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
}, [job, onJobMoved, onJobUpdated]);
|
||||||
|
|
||||||
|
const handleCopyInfo = useCallback(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");
|
||||||
|
}
|
||||||
|
}, [job]);
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (!job) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/30">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
||||||
|
<p className="text-xs text-muted-foreground/70 max-w-[200px]">
|
||||||
|
Select a Ready job to view its application kit and take action.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────
|
||||||
|
PRIMARY ACTION CLUSTER
|
||||||
|
Three actions: Download PDF (primary), Open job (secondary), Mark applied (prominent)
|
||||||
|
───────────────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="space-y-3 pb-4 border-b border-border/40">
|
||||||
|
{/* Primary CTA: Mark Applied - most prominent, one-click */}
|
||||||
|
<Button
|
||||||
|
className="w-full h-11 gap-2 bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-sm"
|
||||||
|
onClick={handleMarkApplied}
|
||||||
|
disabled={isMarkingApplied}
|
||||||
|
>
|
||||||
|
{isMarkingApplied ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Mark Applied
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Secondary actions row */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{/* Show PDF - to verify quickly without download */}
|
||||||
|
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||||
|
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">View</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Download PDF - primary artifact action */}
|
||||||
|
<Button asChild variant="default" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||||
|
<a
|
||||||
|
href={pdfHref}
|
||||||
|
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">Save</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Open job - to verify before applying */}
|
||||||
|
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
|
||||||
|
<a href={jobLink} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">Open</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────
|
||||||
|
APPLICATION KIT SUMMARY
|
||||||
|
Abstract representation of what the PDF contains - verify at a glance
|
||||||
|
───────────────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 py-4 space-y-4">
|
||||||
|
{/* Job identity - confirm this is the right role */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* AI Suitability Reasoning - Why you're a fit */}
|
||||||
|
{job.suitabilityReason && (
|
||||||
|
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wide text-primary/70 mb-1.5 flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
Fit Assessment
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-foreground/90 leading-relaxed font-medium">
|
||||||
|
{job.suitabilityReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tailored summary snippet - shows what's in the PDF */}
|
||||||
|
{tailoredSummary && (
|
||||||
|
<div className="rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
|
||||||
|
Tailored Summary
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-foreground/80 leading-relaxed italic whitespace-pre-wrap">
|
||||||
|
"{tailoredSummary}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project selection - expandable accordion */}
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="projects" className="border-none">
|
||||||
|
<AccordionTrigger className="hover:no-underline py-0 data-[state=open]:pb-2">
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50 text-muted-foreground">
|
||||||
|
<FolderKanban className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 text-left">
|
||||||
|
<div className="text-sm font-medium text-foreground leading-tight">
|
||||||
|
{selectedProjectIds.length} {selectedProjectIds.length === 1 ? "project" : "projects"} selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pt-1 pl-11">
|
||||||
|
<ul className="list-disc text-xs text-muted-foreground space-y-1">
|
||||||
|
{selectedProjectNames.map((name, i) => (
|
||||||
|
<li key={i}>{name}</li>
|
||||||
|
))}
|
||||||
|
{selectedProjectNames.length === 0 && (
|
||||||
|
<li className="list-none italic">No projects selected</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────
|
||||||
|
SECONDARY ACTIONS
|
||||||
|
Fix/More menu - all non-critical actions demoted here
|
||||||
|
───────────────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="pt-3 border-t border-border/40">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-8 gap-2 text-xs text-muted-foreground hover:text-foreground justify-center"
|
||||||
|
>
|
||||||
|
More actions
|
||||||
|
<ChevronUp className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-56">
|
||||||
|
{/* Fix/Edit actions */}
|
||||||
|
<DropdownMenuItem onSelect={onEditTailoring}>
|
||||||
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
|
Edit tailoring
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={handleRegenerate}
|
||||||
|
disabled={isRegenerating}
|
||||||
|
>
|
||||||
|
<RefreshCcw className={cn("mr-2 h-4 w-4", isRegenerating && "animate-spin")} />
|
||||||
|
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onSelect={onEditDescription}>
|
||||||
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
|
Edit job description
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Utility actions */}
|
||||||
|
<DropdownMenuItem onSelect={handleCopyInfo}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy job info
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Destructive actions */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={handleSkip}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Skip this job
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────
|
||||||
|
UNDO BAR (conditional)
|
||||||
|
Lightweight undo option after marking applied
|
||||||
|
───────────────────────────────────────────────────────────────────── */}
|
||||||
|
{recentlyApplied && (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-card px-4 py-2 shadow-lg">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span className="text-sm">
|
||||||
|
<span className="font-medium">{recentlyApplied.jobTitle}</span>
|
||||||
|
<span className="text-muted-foreground"> marked applied</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={() => handleUndoApplied(recentlyApplied.jobId)}
|
||||||
|
>
|
||||||
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -8,4 +8,5 @@ export { JobList } from './JobList';
|
|||||||
export { PipelineProgress } from './PipelineProgress';
|
export { PipelineProgress } from './PipelineProgress';
|
||||||
export { TailoringEditor } from './TailoringEditor';
|
export { TailoringEditor } from './TailoringEditor';
|
||||||
export { DiscoveredPanel } from './DiscoveredPanel';
|
export { DiscoveredPanel } from './DiscoveredPanel';
|
||||||
|
export { ReadyPanel } from './ReadyPanel';
|
||||||
export * from './layout';
|
export * from './layout';
|
||||||
|
|||||||
@ -50,6 +50,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||||
import { PipelineProgress, DiscoveredPanel } from "../components";
|
import { PipelineProgress, DiscoveredPanel } from "../components";
|
||||||
|
import { ReadyPanel } from "../components/ReadyPanel";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { TailoringEditor } from "../components/TailoringEditor";
|
import { TailoringEditor } from "../components/TailoringEditor";
|
||||||
import type { Job, JobSource, JobStatus } from "../../shared/types";
|
import type { Job, JobSource, JobStatus } from "../../shared/types";
|
||||||
@ -913,6 +914,30 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
setSelectedJobId(nextJob?.id ?? null);
|
setSelectedJobId(nextJob?.id ?? null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : activeTab === "ready" ? (
|
||||||
|
/* ReadyPanel for Ready tab - shipping lane workflow: verify → download → apply → mark applied */
|
||||||
|
<ReadyPanel
|
||||||
|
job={selectedJob}
|
||||||
|
onJobUpdated={loadJobs}
|
||||||
|
onJobMoved={(jobId) => {
|
||||||
|
// Select next job in list after current one is moved
|
||||||
|
const currentIndex = activeJobs.findIndex((j) => j.id === jobId);
|
||||||
|
const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
|
||||||
|
setSelectedJobId(nextJob?.id ?? null);
|
||||||
|
}}
|
||||||
|
onEditTailoring={() => {
|
||||||
|
setActiveTab("discovered");
|
||||||
|
// Brief delay to let tab switch, then we're showing generic panel with tailoring
|
||||||
|
setTimeout(() => setDetailTab("tailoring"), 50);
|
||||||
|
}}
|
||||||
|
onEditDescription={() => {
|
||||||
|
setActiveTab("discovered");
|
||||||
|
setTimeout(() => {
|
||||||
|
setDetailTab("description");
|
||||||
|
setIsEditingDescription(true);
|
||||||
|
}, 50);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : !selectedJob ? (
|
) : !selectedJob ? (
|
||||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
|
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
|
||||||
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user