combined JobHeader
This commit is contained in:
parent
d506966d4c
commit
fde32d2c51
90
orchestrator/src/client/components/JobHeader.tsx
Normal file
90
orchestrator/src/client/components/JobHeader.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Calendar, DollarSign, MapPin } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||||
|
import type { Job, JobStatus } from "../../shared/types";
|
||||||
|
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
|
||||||
|
|
||||||
|
interface JobHeaderProps {
|
||||||
|
job: Job;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
||||||
|
const tokens = statusTokens[status] ?? defaultStatusToken;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", tokens.dot)} />
|
||||||
|
{tokens.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||||
|
if (score == null) {
|
||||||
|
return <span className="text-[10px] text-muted-foreground/60">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
|
||||||
|
<div className="h-1 w-12 rounded-full bg-muted/30">
|
||||||
|
<div
|
||||||
|
className="h-1 rounded-full bg-primary/50"
|
||||||
|
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="tabular-nums">{score}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||||
|
const deadline = formatDate(job.deadline);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-3", className)}>
|
||||||
|
{/* Detail header: lighter weight than list items */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{job.employer}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||||
|
{sourceLabel[job.source]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tertiary metadata - subdued */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground/70">
|
||||||
|
{job.location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{job.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deadline && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{deadline}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{job.salary && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<DollarSign className="h-3 w-3" />
|
||||||
|
{job.salary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status and score: single line, subdued */}
|
||||||
|
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||||
|
<StatusPill status={job.status} />
|
||||||
|
<ScoreMeter score={job.suitabilityScore} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -41,7 +41,7 @@ import {
|
|||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
|
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { FitAssessment } from ".";
|
import { FitAssessment, JobHeader } from ".";
|
||||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||||
|
|
||||||
interface ReadyPanelProps {
|
interface ReadyPanelProps {
|
||||||
@ -217,6 +217,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
<JobHeader job={job} className="pb-4 border-b border-border/40" />
|
||||||
|
|
||||||
{/* ─────────────────────────────────────────────────────────────────────
|
{/* ─────────────────────────────────────────────────────────────────────
|
||||||
PRIMARY ACTION CLUSTER
|
PRIMARY ACTION CLUSTER
|
||||||
All actions in one line: View, Save, Open, and Mark Applied
|
All actions in one line: View, Save, Open, and Mark Applied
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Calendar, DollarSign, ExternalLink, Loader2, MapPin, Sparkles, XCircle } from "lucide-react";
|
import { ExternalLink, Loader2, Sparkles, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
import { FitAssessment } from "../FitAssessment";
|
import { FitAssessment, JobHeader } from "..";
|
||||||
import { formatDate, sourceLabel } from "@/lib/utils";
|
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
import { CollapsibleSection } from "./CollapsibleSection";
|
import { CollapsibleSection } from "./CollapsibleSection";
|
||||||
import { getPlainDescription } from "./helpers";
|
import { getPlainDescription } from "./helpers";
|
||||||
@ -25,7 +23,6 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
isSkipping,
|
isSkipping,
|
||||||
}) => {
|
}) => {
|
||||||
const [showDescription, setShowDescription] = useState(false);
|
const [showDescription, setShowDescription] = useState(false);
|
||||||
const deadline = formatDate(job.deadline);
|
|
||||||
const jobLink = job.applicationLink || job.jobUrl;
|
const jobLink = job.applicationLink || job.jobUrl;
|
||||||
|
|
||||||
const description = useMemo(
|
const description = useMemo(
|
||||||
@ -35,105 +32,66 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col h-full'>
|
<div className='flex flex-col h-full'>
|
||||||
<div className='space-y-3 pb-4'>
|
<div className='space-y-4 pb-4'>
|
||||||
<div className='flex items-start justify-between gap-2'>
|
<JobHeader job={job} />
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<h2 className='text-base font-semibold text-foreground/90 leading-tight'>
|
|
||||||
{job.title}
|
|
||||||
</h2>
|
|
||||||
<p className='text-sm text-muted-foreground mt-0.5'>{job.employer}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col items-center justify-center'>
|
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
|
||||||
<Badge
|
|
||||||
variant='outline'
|
|
||||||
className='text-[10px] uppercase tracking-wide text-muted-foreground border-border/50 shrink-0'
|
|
||||||
>
|
|
||||||
{sourceLabel[job.source]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(job.location || deadline || job.salary) && (
|
|
||||||
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 justify-between'>
|
|
||||||
{job.location && (
|
|
||||||
<span className='flex items-center gap-1'>
|
|
||||||
<MapPin className='h-3 w-3' />
|
|
||||||
{job.location}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{deadline && (
|
|
||||||
<span className='flex items-center gap-1'>
|
|
||||||
<Calendar className='h-3 w-3' />
|
|
||||||
{deadline}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{job.salary && (
|
|
||||||
<span className='flex items-center gap-1'>
|
|
||||||
<DollarSign className='h-3 w-3' />
|
|
||||||
{job.salary}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-2 pt-2 sm:flex-row'>
|
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='default'
|
size='default'
|
||||||
onClick={onSkip}
|
onClick={onSkip}
|
||||||
disabled={isSkipping}
|
disabled={isSkipping}
|
||||||
className='flex-1 h-11 text-sm text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
|
className='flex-1 h-11 text-sm text-muted-foreground hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
|
||||||
>
|
>
|
||||||
{isSkipping ? (
|
{isSkipping ? (
|
||||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className='mr-2 h-4 w-4' />
|
<XCircle className='mr-2 h-4 w-4' />
|
||||||
)}
|
)}
|
||||||
Skip
|
Skip Job
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='default'
|
size='default'
|
||||||
onClick={onTailor}
|
onClick={onTailor}
|
||||||
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs'
|
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs shadow-sm'
|
||||||
>
|
>
|
||||||
<Sparkles className='mr-2 h-4 w-4' />
|
<Sparkles className='mr-2 h-4 w-4' />
|
||||||
Tailor
|
Start Tailoring
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className='opacity-50' />
|
<Separator className='opacity-40' />
|
||||||
|
|
||||||
<div className='flex-1 py-4 space-y-4 overflow-y-auto'>
|
<div className='flex-1 py-6 space-y-6 overflow-y-auto'>
|
||||||
<FitAssessment job={job} />
|
<FitAssessment job={job} />
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
isOpen={showDescription}
|
isOpen={showDescription}
|
||||||
onToggle={() => setShowDescription((prev) => !prev)}
|
onToggle={() => setShowDescription((prev) => !prev)}
|
||||||
label={`${showDescription ? "Hide" : "View"} full job description`}
|
label={`${showDescription ? "Hide" : "View"} Full Job Description`}
|
||||||
>
|
>
|
||||||
<div className='rounded-lg border border-border/40 bg-muted/5 p-3 max-h-[300px] overflow-y-auto'>
|
<div className='rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner'>
|
||||||
<p className='text-xs text-muted-foreground/80 whitespace-pre-wrap leading-relaxed'>
|
<p className='text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed'>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className='opacity-50' />
|
<Separator className='opacity-40' />
|
||||||
|
|
||||||
<div className='pt-4 pb-2'>
|
<div className='pt-6 pb-2'>
|
||||||
{jobLink ? (
|
{jobLink ? (
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<a
|
<a
|
||||||
href={jobLink}
|
href={jobLink}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
className='inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors'
|
||||||
>
|
>
|
||||||
<ExternalLink className='h-3 w-3' />
|
<ExternalLink className='h-3.5 w-3.5' />
|
||||||
View original listing
|
Original Job Listing
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -141,3 +99,4 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export { Header } from './Header';
|
|||||||
export { Stats } from './Stats';
|
export { Stats } from './Stats';
|
||||||
export { StatusBadge } from './StatusBadge';
|
export { StatusBadge } from './StatusBadge';
|
||||||
export { ScoreIndicator } from './ScoreIndicator';
|
export { ScoreIndicator } from './ScoreIndicator';
|
||||||
|
export { JobHeader } from './JobHeader';
|
||||||
export { FitAssessment } from './FitAssessment';
|
export { FitAssessment } from './FitAssessment';
|
||||||
export { PipelineProgress } from './PipelineProgress';
|
export { PipelineProgress } from './PipelineProgress';
|
||||||
export { TailoringEditor } from './TailoringEditor';
|
export { TailoringEditor } from './TailoringEditor';
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Copy,
|
Copy,
|
||||||
DollarSign,
|
|
||||||
Edit2,
|
Edit2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Save,
|
Save,
|
||||||
@ -29,9 +26,9 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn, copyTextToClipboard, formatDate, formatJobForWebhook, sourceLabel, safeFilenamePart, stripHtml } from "@/lib/utils";
|
import { cn, copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml } from "@/lib/utils";
|
||||||
|
|
||||||
import { DiscoveredPanel } from "../../components";
|
import { DiscoveredPanel, JobHeader } from "../../components";
|
||||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
@ -48,40 +45,6 @@ interface JobDetailPanelProps {
|
|||||||
onSetActiveTab: (tab: FilterTab) => void;
|
onSetActiveTab: (tab: FilterTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subdued status pill for inspector panel - not competing with list
|
|
||||||
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
|
||||||
const tokens = statusTokens[status] ?? defaultStatusToken;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", tokens.dot)} />
|
|
||||||
{tokens.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compact score meter for inspector panel
|
|
||||||
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
|
||||||
if (score == null) {
|
|
||||||
return <span className="text-[10px] text-muted-foreground/60">-</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
|
|
||||||
<div className="h-1 w-12 rounded-full bg-muted/30">
|
|
||||||
<div
|
|
||||||
className="h-1 rounded-full bg-primary/50"
|
|
||||||
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="tabular-nums">{score}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||||
activeTab,
|
activeTab,
|
||||||
activeJobs,
|
activeJobs,
|
||||||
@ -258,7 +221,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const selectedPdfHref = selectedJob
|
const selectedPdfHref = selectedJob
|
||||||
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
|
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
|
||||||
: "#";
|
: "#";
|
||||||
const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null;
|
|
||||||
const canApply = selectedJob?.status === "ready";
|
const canApply = selectedJob?.status === "ready";
|
||||||
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||||
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||||
@ -309,44 +271,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Detail header: lighter weight than list items */}
|
<JobHeader job={selectedJob} />
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-semibold text-foreground/90">{selectedJob.title}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{selectedJob.employer}</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
|
||||||
{sourceLabel[selectedJob.source]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tertiary metadata - subdued */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground/70">
|
|
||||||
{selectedJob.location && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
{selectedJob.location}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{selectedDeadline && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-3 w-3" />
|
|
||||||
{selectedDeadline}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{selectedJob.salary && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<DollarSign className="h-3 w-3" />
|
|
||||||
{selectedJob.salary}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status and score: single line, subdued */}
|
|
||||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
|
||||||
<StatusPill status={selectedJob.status} />
|
|
||||||
<ScoreMeter score={selectedJob.suitabilityScore} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user