mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-15 15:26:59 +10:00
feat(applications): job application tracker with AI copilot
Add an Applications module at /dashboard/applications: pipeline board (dnd-kit), table view with bulk actions, Insights (fit tiles, funnel, sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and Add/Edit/Detail slide-overs. Each application links a live Reactive Resume. AI "Application Copilot" (applications.ai.*): job-posting autofill, resume↔job match score (fit ring), resume tailoring, and cover-letter / follow-up drafting — via the user's configured provider. Board cards + table rows get context menus (edit / move / archive / delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox. Also includes local TanStack devtools setup and toolchain bumps. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f
This commit is contained in:
@@ -77,7 +77,7 @@
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"tsdown": "^0.22.3",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -91,12 +91,16 @@
|
||||
"@lingui/vite-plugin": "^6.4.0",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/devtools-vite": "^0.8.1",
|
||||
"@tanstack/react-devtools": "^0.10.8",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.19",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowRightIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
PencilSimpleIcon,
|
||||
TrashIcon,
|
||||
TrayArrowUpIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
|
||||
type Props = {
|
||||
application: Application;
|
||||
onEdit: (application: Application) => void;
|
||||
// Whether the trigger should only appear on hover of the parent (cards). Rows keep it visible.
|
||||
showOnHover?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Shared kebab menu for board cards and table rows: edit, move stage, archive, delete.
|
||||
export function ApplicationActionsMenu({ application, onEdit, showOnHover, className }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
|
||||
};
|
||||
|
||||
const update = useMutation(
|
||||
orpc.applications.update.mutationOptions({
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t`Something went wrong. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const remove = useMutation(
|
||||
orpc.applications.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application deleted.`);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't delete the application.`),
|
||||
}),
|
||||
);
|
||||
|
||||
// Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
|
||||
const stop = (event: React.SyntheticEvent) => event.stopPropagation();
|
||||
|
||||
return (
|
||||
<div className={cn("shrink-0", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t`Application actions`}
|
||||
onClick={stop}
|
||||
onPointerDown={stop}
|
||||
className={cn(
|
||||
"size-6 text-muted-foreground",
|
||||
showOnHover &&
|
||||
"opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100 data-[popup-open]:opacity-100",
|
||||
)}
|
||||
>
|
||||
<DotsThreeVerticalIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => onEdit(application)}>
|
||||
<PencilSimpleIcon />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ArrowRightIcon />
|
||||
<Trans>Move to</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{STAGES.map((stage) => (
|
||||
<DropdownMenuItem
|
||||
key={stage.value}
|
||||
disabled={stage.value === application.status}
|
||||
onClick={() => update.mutate({ id: application.id, status: stage.value })}
|
||||
>
|
||||
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
|
||||
{stage.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem onClick={() => update.mutate({ id: application.id, archived: !application.archived })}>
|
||||
{application.archived ? <TrayArrowUpIcon /> : <ArchiveIcon />}
|
||||
{application.archived ? <Trans>Unarchive</Trans> : <Trans>Archive</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" onClick={() => remove.mutate({ id: application.id })}>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowsClockwiseIcon,
|
||||
CaretRightIcon,
|
||||
CopyIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
MagicWandIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
SparkleIcon,
|
||||
SpinnerGapIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
|
||||
// Score bands drive the ring color and the label — a job-fit gauge, not a generic percentage.
|
||||
function band(score: number) {
|
||||
if (score >= 75) return { color: "#10b981", label: t`Strong fit` };
|
||||
if (score >= 50) return { color: "#f59e0b", label: t`Worth a shot` };
|
||||
return { color: "#f43f5e", label: t`A stretch` };
|
||||
}
|
||||
|
||||
function aiGaps(app: Application): string[] {
|
||||
const meta = app.aiMetadata as { matchScore?: { gaps?: unknown } } | null | undefined;
|
||||
const gaps = meta?.matchScore?.gaps;
|
||||
return Array.isArray(gaps) ? gaps.filter((gap): gap is string => typeof gap === "string") : [];
|
||||
}
|
||||
|
||||
// The signature element: a circular resume-fit gauge that animates to the score.
|
||||
function FitRing({ score }: { score: number }) {
|
||||
const size = 60;
|
||||
const stroke = 5;
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const { color } = band(score);
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0 -rotate-90" aria-hidden="true">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
className="text-muted"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={c - (Math.max(0, Math.min(100, score)) / 100) * c}
|
||||
className="transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionRowProps = {
|
||||
icon: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ActionRow({ icon, title, description, disabled, pending, onClick }: ActionRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pending}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group/row flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors",
|
||||
"hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{pending ? <SpinnerGapIcon className="animate-spin" /> : icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium text-sm">{title}</span>
|
||||
<span className="block truncate text-muted-foreground text-xs">{description}</span>
|
||||
</span>
|
||||
<CaretRightIcon className="shrink-0 text-muted-foreground/50 transition-transform group-hover/row:translate-x-0.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = { application: Application };
|
||||
|
||||
export function ApplicationAiCopilot({ application }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }),
|
||||
});
|
||||
};
|
||||
|
||||
const matchScore = useMutation(
|
||||
orpc.applications.ai.matchScore.mutationOptions({
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => toast.error(error.message || t`Match scoring failed.`),
|
||||
}),
|
||||
);
|
||||
const tailorResume = useMutation(
|
||||
orpc.applications.ai.tailorResume.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
toast.success(t`Created "${result.name}" and linked it to this application.`);
|
||||
},
|
||||
onError: (error) => toast.error(error.message || t`Tailoring failed.`),
|
||||
}),
|
||||
);
|
||||
const draftMessage = useMutation(
|
||||
orpc.applications.ai.draftMessage.mutationOptions({
|
||||
onSuccess: (result, variables) => setDraft({ kind: variables.kind, text: result.text }),
|
||||
onError: (error) => toast.error(error.message || t`Drafting failed.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = matchScore.isPending || tailorResume.isPending || draftMessage.isPending;
|
||||
const canScore = !!application.resumeId && !!application.jobDescription;
|
||||
const score = application.matchScore;
|
||||
const gaps = aiGaps(application);
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-primary/15 bg-primary/[0.04]">
|
||||
<header className="flex items-center gap-2 px-3.5 pt-3">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<SparkleIcon weight="fill" className="size-3" />
|
||||
</span>
|
||||
<span className="font-medium text-sm">
|
||||
<Trans>Application Copilot</Trans>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Fit gauge — the signature. Adapts to whether prerequisites are met and whether a score exists. */}
|
||||
<div className="px-3.5 py-3">
|
||||
{!canScore ? (
|
||||
<p className="rounded-lg bg-muted/50 p-2.5 text-muted-foreground text-xs">
|
||||
<Trans>Link a resume and add a job description (Edit) to score your fit and tailor a copy.</Trans>
|
||||
</p>
|
||||
) : score == null ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => matchScore.mutate({ id: application.id })}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-primary/20 border-dashed p-2.5 text-left transition-colors hover:bg-primary/10 disabled:opacity-60"
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{matchScore.isPending ? (
|
||||
<SpinnerGapIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<SparkleIcon className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-medium text-sm">
|
||||
{matchScore.isPending ? <Trans>Scoring your fit…</Trans> : <Trans>Score my fit</Trans>}
|
||||
</span>
|
||||
<span className="block text-muted-foreground text-xs">
|
||||
<Trans>See how this resume matches the posting</Trans>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<FitRing score={score} />
|
||||
<span className="absolute font-semibold text-base tabular-nums">{score}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm" style={{ color: band(score).color }}>
|
||||
{band(score).label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => matchScore.mutate({ id: application.id })}
|
||||
className="text-muted-foreground text-xs hover:text-foreground disabled:opacity-50"
|
||||
title={t`Re-score`}
|
||||
>
|
||||
<ArrowsClockwiseIcon className={cn("size-3.5", matchScore.isPending && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
{gaps.length > 0 && (
|
||||
<p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs">
|
||||
<Trans>Gaps:</Trans> {gaps.slice(0, 3).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-primary/10 border-t px-2 py-2">
|
||||
<ActionRow
|
||||
icon={<MagicWandIcon />}
|
||||
title={<Trans>Tailor my resume</Trans>}
|
||||
description={t`Create a copy tuned to this job`}
|
||||
disabled={!canScore}
|
||||
pending={tailorResume.isPending}
|
||||
onClick={() => tailorResume.mutate({ id: application.id })}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<EnvelopeSimpleIcon />}
|
||||
title={<Trans>Draft a cover letter</Trans>}
|
||||
description={t`From your resume and the posting`}
|
||||
pending={draftMessage.isPending && draft?.kind !== "follow-up"}
|
||||
onClick={() => draftMessage.mutate({ id: application.id, kind: "cover-letter" })}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<PaperPlaneTiltIcon />}
|
||||
title={<Trans>Draft a follow-up</Trans>}
|
||||
description={t`A friendly nudge for the recruiter`}
|
||||
pending={draftMessage.isPending && draft?.kind === "follow-up"}
|
||||
onClick={() => draftMessage.mutate({ id: application.id, kind: "follow-up" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft && (
|
||||
<div className="border-primary/10 border-t bg-card/60 p-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="font-medium text-xs">
|
||||
{draft.kind === "cover-letter" ? <Trans>Cover letter draft</Trans> : <Trans>Follow-up draft</Trans>}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(draft.text);
|
||||
toast.success(t`Copied to clipboard.`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-3.5" /> <Trans>Copy</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
<Trans>Dismiss</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-muted-foreground text-xs leading-relaxed">
|
||||
{draft.text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Application } from "../types";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { FileTextIcon, MapPinIcon } from "@phosphor-icons/react";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { ApplicationActionsMenu } from "./application-actions-menu";
|
||||
|
||||
// Deterministic accent color for a company's logo tile (design shows colored initials tiles).
|
||||
const TILE_COLORS = [
|
||||
"bg-rose-500",
|
||||
"bg-orange-500",
|
||||
"bg-amber-500",
|
||||
"bg-emerald-500",
|
||||
"bg-teal-500",
|
||||
"bg-sky-500",
|
||||
"bg-indigo-500",
|
||||
"bg-violet-500",
|
||||
"bg-fuchsia-500",
|
||||
];
|
||||
|
||||
function tileColor(seed: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
|
||||
return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length];
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: Application;
|
||||
onClick?: () => void;
|
||||
onEdit?: (application: Application) => void;
|
||||
className?: string;
|
||||
dragging?: boolean;
|
||||
};
|
||||
|
||||
export function ApplicationCard({ application, onClick, onEdit, className, dragging }: Props) {
|
||||
const followUp = application.followUpAt && !application.archived;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: the card nests an actions-menu <button>, so it can't itself be a <button>; it stays keyboard-operable via role + tabIndex + onKeyDown.
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group relative w-full cursor-pointer rounded-xl border border-border bg-card p-3 text-left shadow-sm outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
dragging && "opacity-60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{onEdit && (
|
||||
<ApplicationActionsMenu
|
||||
application={application}
|
||||
onEdit={onEdit}
|
||||
showOnHover
|
||||
className="absolute end-1.5 top-1.5"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg font-bold text-white text-xs",
|
||||
tileColor(application.company),
|
||||
)}
|
||||
>
|
||||
{getInitials(application.company)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-sm tracking-tight">{application.role}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">{application.company}</div>
|
||||
</div>
|
||||
{followUp && (
|
||||
<span
|
||||
title="Needs follow-up"
|
||||
className={cn("mt-1 size-2 shrink-0 rounded-full bg-amber-500 ring-2 ring-amber-500/25", onEdit && "me-6")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(application.location || application.salary) && (
|
||||
<div className="mt-2.5 flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{application.location && (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<MapPinIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{application.location}</span>
|
||||
</span>
|
||||
)}
|
||||
{application.location && application.salary && <span className="opacity-40">·</span>}
|
||||
{application.salary && <span className="font-medium text-foreground">{application.salary}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(application.resumeId || application.source) && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{application.resumeId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/50 px-2 py-0.5 font-medium text-[11px] text-muted-foreground">
|
||||
<FileTextIcon className="size-3" />
|
||||
<Trans>Resume linked</Trans>
|
||||
</span>
|
||||
)}
|
||||
{application.source && (
|
||||
<span className="inline-flex items-center rounded-md border border-border px-2 py-0.5 font-medium text-[11px] text-muted-foreground">
|
||||
{application.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, ArrowSquareOutIcon, PencilSimpleIcon, TrashIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
import { ApplicationAiCopilot } from "./application-ai-copilot";
|
||||
|
||||
const stageIndex = (status: ApplicationStatus) => STAGES.findIndex((s) => s.value === status);
|
||||
|
||||
type Props = {
|
||||
application: Application | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEdit: (application: Application) => void;
|
||||
};
|
||||
|
||||
export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [note, setNote] = useState("");
|
||||
const id = application?.id;
|
||||
|
||||
// Reset the draft note when switching to a different application (React's "adjust state during
|
||||
// render" pattern) so typed-but-unsent text doesn't leak across applications.
|
||||
const [noteFor, setNoteFor] = useState(id);
|
||||
if (id !== noteFor) {
|
||||
setNoteFor(id);
|
||||
setNote("");
|
||||
}
|
||||
|
||||
const { data } = useQuery({
|
||||
...orpc.applications.getById.queryOptions({ input: { id: id ?? "" } }),
|
||||
enabled: !!id,
|
||||
...(application ? { initialData: application } : {}),
|
||||
});
|
||||
|
||||
const current = data ?? application;
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.campaigns.queryKey() });
|
||||
if (id) void queryClient.invalidateQueries({ queryKey: orpc.applications.getById.queryKey({ input: { id } }) });
|
||||
};
|
||||
|
||||
const update = useMutation(
|
||||
orpc.applications.update.mutationOptions({
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t`Something went wrong. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const addNote = useMutation(
|
||||
orpc.applications.addNote.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setNote("");
|
||||
invalidate();
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't save the note.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const remove = useMutation(
|
||||
orpc.applications.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application deleted.`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't delete the application.`),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const idx = stageIndex(current.status);
|
||||
const nextStage = idx >= 0 && idx < STAGES.length - 2 ? STAGES[idx + 1] : null;
|
||||
|
||||
return (
|
||||
<Sheet open={!!application} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
|
||||
<SheetHeader className="gap-3">
|
||||
<div className="flex items-start justify-between gap-2 pe-8">
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate">{current.role}</SheetTitle>
|
||||
<div className="truncate text-muted-foreground text-sm">
|
||||
{current.company}
|
||||
{current.location ? ` · ${current.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={() => onEdit(current)}>
|
||||
<PencilSimpleIcon />
|
||||
<Trans>Edit</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* stage stepper */}
|
||||
<div className="flex gap-1.5">
|
||||
{STAGES.map((stage, i) => (
|
||||
<span
|
||||
key={stage.value}
|
||||
title={stage.label}
|
||||
className={cn("h-1.5 flex-1 rounded-full", i <= idx ? "" : "bg-muted")}
|
||||
style={i <= idx ? { background: stage.color } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{STAGES[idx]?.label ?? current.status}</span>
|
||||
{nextStage && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending}
|
||||
onClick={() => update.mutate({ id: current.id, status: nextStage.value })}
|
||||
>
|
||||
<Trans>Move to</Trans> {nextStage.label}
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-5 overflow-y-auto px-4 py-4">
|
||||
{/* key facts */}
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<Fact label={t`Salary`} value={current.salary} />
|
||||
<Fact label={t`Source`} value={current.source} />
|
||||
<Fact label={t`Applied on`} value={new Date(current.appliedAt).toLocaleDateString()} />
|
||||
<Fact label={t`Campaign`} value={current.campaign} />
|
||||
</dl>
|
||||
|
||||
{current.sourceUrl && (
|
||||
<a
|
||||
href={current.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-primary text-sm hover:underline"
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Job posting</Trans>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* linked resume */}
|
||||
<Section title={t`Documents sent`}>
|
||||
{current.resumeId ? (
|
||||
<Link
|
||||
to="/builder/$resumeId"
|
||||
params={{ resumeId: current.resumeId }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border p-2.5 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex size-8 items-center justify-center rounded-md bg-primary/10 font-bold text-[10px] text-primary">
|
||||
RXR
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<Trans>Linked Reactive Resume</Trans>
|
||||
</span>
|
||||
<ArrowSquareOutIcon className="text-muted-foreground" />
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>No resume linked.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* contacts */}
|
||||
{current.contacts.length > 0 && (
|
||||
<Section title={t`Contacts`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{current.contacts.map((contact, i) => (
|
||||
<div key={`${contact.name}-${i}`} className="flex items-center gap-3 text-sm">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
|
||||
{contact.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{contact.name}</div>
|
||||
{contact.role && <div className="truncate text-muted-foreground text-xs">{contact.role}</div>}
|
||||
</div>
|
||||
{contact.type && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{contact.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* follow-up */}
|
||||
{current.followUpAt && (
|
||||
<Section title={t`Follow-up`}>
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-2.5 text-sm">
|
||||
<span className="font-medium">{new Date(current.followUpAt).toLocaleDateString()}</span>
|
||||
{current.followUpNote ? ` — ${current.followUpNote}` : ""}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* activity timeline */}
|
||||
<Section title={t`Timeline & activity`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...current.activity]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.map((event) => (
|
||||
<div key={event.id} className="flex gap-2.5 text-sm">
|
||||
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>{event.text}</div>
|
||||
<div className="text-muted-foreground text-xs">{new Date(event.at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Input
|
||||
value={note}
|
||||
placeholder={t`Add a note or log activity…`}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && note.trim()) addNote.mutate({ id: current.id, text: note.trim() });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!note.trim() || addNote.isPending}
|
||||
onClick={() => addNote.mutate({ id: current.id, text: note.trim() })}
|
||||
>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<ApplicationAiCopilot application={current} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-border border-t p-4">
|
||||
{current.status !== "rejected" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={update.isPending}
|
||||
onClick={() => update.mutate({ id: current.id, status: "rejected" })}
|
||||
>
|
||||
<XCircleIcon />
|
||||
<Trans>Mark rejected</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => update.mutate({ id: current.id, archived: !current.archived })}
|
||||
>
|
||||
{current.archived ? <Trans>Unarchive</Trans> : <Trans>Archive</Trans>}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ms-auto text-destructive"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate({ id: current.id })}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="mt-0.5 font-medium">{value || "—"}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="font-semibold text-muted-foreground text-xs uppercase tracking-wide">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { SparkleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@reactive-resume/ui/components/sheet";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
|
||||
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company website", "Referral", "Recruiter", "Other"].map((s) => ({
|
||||
value: s,
|
||||
label: s,
|
||||
}));
|
||||
|
||||
const EMPTY = {
|
||||
company: "",
|
||||
role: "",
|
||||
location: "",
|
||||
salary: "",
|
||||
source: "",
|
||||
status: "saved" as ApplicationStatus,
|
||||
resumeId: "",
|
||||
campaign: "",
|
||||
sourceUrl: "",
|
||||
jobDescription: "",
|
||||
followUpAt: "",
|
||||
followUpNote: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
type FormState = typeof EMPTY;
|
||||
|
||||
function toForm(app: Application): FormState {
|
||||
return {
|
||||
company: app.company,
|
||||
role: app.role,
|
||||
location: app.location ?? "",
|
||||
salary: app.salary ?? "",
|
||||
source: app.source ?? "",
|
||||
status: app.status,
|
||||
resumeId: app.resumeId ?? "",
|
||||
campaign: app.campaign ?? "",
|
||||
sourceUrl: app.sourceUrl ?? "",
|
||||
jobDescription: app.jobDescription ?? "",
|
||||
followUpAt: app.followUpAt ? new Date(app.followUpAt).toISOString().slice(0, 10) : "",
|
||||
followUpNote: app.followUpNote ?? "",
|
||||
notes: app.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
// When provided, the sheet edits this application instead of creating a new one.
|
||||
application?: Application | null;
|
||||
};
|
||||
|
||||
export function ApplicationFormSheet({ open, onOpenChange, application }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!application;
|
||||
|
||||
const [form, setForm] = useState<FormState>(application ? toForm(application) : EMPTY);
|
||||
|
||||
// Re-sync the form when the sheet's target changes (a different app, or create ↔ edit).
|
||||
const [syncedId, setSyncedId] = useState(application?.id ?? null);
|
||||
if ((application?.id ?? null) !== syncedId) {
|
||||
setSyncedId(application?.id ?? null);
|
||||
setForm(application ? toForm(application) : EMPTY);
|
||||
}
|
||||
|
||||
const { data: resumes } = useQuery(orpc.resume.list.queryOptions());
|
||||
const resumeOptions = (resumes ?? []).map((resume) => ({ value: resume.id, label: resume.name }));
|
||||
|
||||
const { data: campaigns } = useQuery(orpc.applications.campaigns.queryOptions());
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.campaigns.queryKey() });
|
||||
if (application) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const create = useMutation(
|
||||
orpc.applications.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application added to your pipeline.`);
|
||||
setForm(EMPTY);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't add the application. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const update = useMutation(
|
||||
orpc.applications.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application updated.`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't save your changes. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const autofill = useMutation(
|
||||
orpc.applications.ai.autofill.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
company: result.company || prev.company,
|
||||
role: result.role || prev.role,
|
||||
location: result.location || prev.location,
|
||||
salary: result.salary || prev.salary,
|
||||
jobDescription: result.jobDescription || prev.jobDescription,
|
||||
}));
|
||||
toast.success(t`Filled in what we could from the posting.`);
|
||||
},
|
||||
onError: (error) => toast.error(error.message || t`Auto-fill failed. Paste the description instead.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
const submit = () => {
|
||||
if (!form.company.trim() || !form.role.trim()) return;
|
||||
const payload = {
|
||||
company: form.company.trim(),
|
||||
role: form.role.trim(),
|
||||
status: form.status,
|
||||
location: form.location.trim() || null,
|
||||
salary: form.salary.trim() || null,
|
||||
source: form.source || null,
|
||||
resumeId: form.resumeId || null,
|
||||
campaign: form.campaign.trim() || null,
|
||||
sourceUrl: form.sourceUrl.trim() || null,
|
||||
jobDescription: form.jobDescription.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
followUpNote: form.followUpNote.trim() || null,
|
||||
followUpAt: form.followUpAt ? new Date(form.followUpAt) : null,
|
||||
};
|
||||
if (application) update.mutate({ id: application.id, ...payload });
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEditing ? <Trans>Edit application</Trans> : <Trans>Add application</Trans>}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isEditing ? (
|
||||
<Trans>Update this application's details.</Trans>
|
||||
) : (
|
||||
<Trans>Track a job you're applying to and link the resume you sent.</Trans>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
{/* AI job-posting autofill: extracts the fields below from a posting URL. */}
|
||||
{!isEditing && (
|
||||
<div className="rounded-lg border border-border border-dashed p-3">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Paste a job posting URL</Trans>
|
||||
</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<Input
|
||||
value={form.sourceUrl}
|
||||
placeholder="https://…"
|
||||
onChange={(event) => set("sourceUrl", event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!form.sourceUrl.trim() || autofill.isPending}
|
||||
onClick={() => autofill.mutate({ sourceUrl: form.sourceUrl.trim() })}
|
||||
>
|
||||
<SparkleIcon />
|
||||
{autofill.isPending ? <Trans>Reading…</Trans> : <Trans>Auto-fill</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
<Trans>Let AI read the posting and fill the fields below.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label={t`Company`} required>
|
||||
<Input value={form.company} onChange={(event) => set("company", event.target.value)} />
|
||||
</Field>
|
||||
<Field label={t`Role / title`} required>
|
||||
<Input value={form.role} onChange={(event) => set("role", event.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t`Location`}>
|
||||
<Input value={form.location} onChange={(event) => set("location", event.target.value)} />
|
||||
</Field>
|
||||
<Field label={t`Salary range`}>
|
||||
<Input value={form.salary} onChange={(event) => set("salary", event.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t`Source`}>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={form.source || null}
|
||||
options={SOURCE_OPTIONS}
|
||||
placeholder={t`Select…`}
|
||||
onValueChange={(value) => set("source", value ?? "")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t`Stage`}>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={form.status}
|
||||
options={STAGES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
onValueChange={(value) => value && set("status", value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t`Resume used`}>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={form.resumeId || null}
|
||||
options={resumeOptions}
|
||||
placeholder={t`Link a Reactive Resume…`}
|
||||
showClear
|
||||
emptyMessage={t`No resumes yet.`}
|
||||
onValueChange={(value) => set("resumeId", value ?? "")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t`Campaign`}>
|
||||
<Input
|
||||
value={form.campaign}
|
||||
list="application-campaigns"
|
||||
placeholder={t`e.g. Spring 2026 · New Grad`}
|
||||
onChange={(event) => set("campaign", event.target.value)}
|
||||
/>
|
||||
<datalist id="application-campaigns">
|
||||
{(campaigns ?? []).map((campaign) => (
|
||||
<option key={campaign.name} value={campaign.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t`Follow-up date`}>
|
||||
<Input type="date" value={form.followUpAt} onChange={(event) => set("followUpAt", event.target.value)} />
|
||||
</Field>
|
||||
<Field label={t`Follow-up note`}>
|
||||
<Input value={form.followUpNote} onChange={(event) => set("followUpNote", event.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t`Job description`}>
|
||||
<Textarea
|
||||
value={form.jobDescription}
|
||||
rows={3}
|
||||
placeholder={t`Paste the posting — powers AI match scoring and tailoring.`}
|
||||
onChange={(event) => set("jobDescription", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t`Notes`}>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
rows={3}
|
||||
placeholder={t`Referred by…, things to emphasize, etc.`}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="button" disabled={!form.company.trim() || !form.role.trim() || pending} onClick={submit}>
|
||||
{isEditing ? <Trans>Save changes</Trans> : <Trans>Add to pipeline</Trans>}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
||||
import type { Application } from "../types";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
import { ApplicationCard } from "./application-card";
|
||||
|
||||
type Props = {
|
||||
applications: Application[];
|
||||
onOpen: (application: Application) => void;
|
||||
onEdit: (application: Application) => void;
|
||||
};
|
||||
|
||||
export function ApplicationBoard({ applications, onOpen, onEdit }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// A small activation distance so a click still opens the detail panel instead of starting a drag.
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
const listKey = applicationsListQueryKey();
|
||||
|
||||
const move = useMutation(
|
||||
orpc.applications.update.mutationOptions({
|
||||
onMutate: async ({ id, status }) => {
|
||||
await queryClient.cancelQueries({ queryKey: listKey });
|
||||
const previous = queryClient.getQueryData<Application[]>(listKey);
|
||||
queryClient.setQueryData<Application[]>(listKey, (rows) =>
|
||||
(rows ?? []).map((row) => (row.id === id && status ? { ...row, status } : row)),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(listKey, context.previous);
|
||||
toast.error(t`Couldn't move the application. Please try again.`);
|
||||
},
|
||||
onSettled: () => void queryClient.invalidateQueries({ queryKey: listKey }),
|
||||
}),
|
||||
);
|
||||
|
||||
const byStage = useMemo(() => {
|
||||
const map = new Map<ApplicationStatus, Application[]>(STAGES.map((s) => [s.value, []]));
|
||||
for (const app of applications) map.get(app.status)?.push(app);
|
||||
return map;
|
||||
}, [applications]);
|
||||
|
||||
const activeApp = activeId ? applications.find((a) => a.id === activeId) : null;
|
||||
|
||||
const onDragStart = (event: DragStartEvent) => setActiveId(String(event.active.id));
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const target = over.id as ApplicationStatus;
|
||||
const app = applications.find((a) => a.id === active.id);
|
||||
if (!app || app.status === target) return;
|
||||
move.mutate({ id: app.id, status: target });
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
<div className="flex h-full min-h-0 gap-4 overflow-x-auto pb-4">
|
||||
{STAGES.map((stage) => (
|
||||
<Column
|
||||
key={stage.value}
|
||||
stage={stage}
|
||||
applications={byStage.get(stage.value) ?? []}
|
||||
onOpen={onOpen}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
type ColumnProps = {
|
||||
stage: (typeof STAGES)[number];
|
||||
applications: Application[];
|
||||
onOpen: (application: Application) => void;
|
||||
onEdit: (application: Application) => void;
|
||||
};
|
||||
|
||||
function Column({ stage, applications, onOpen, onEdit }: ColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: stage.value });
|
||||
|
||||
return (
|
||||
<div className="flex w-72 shrink-0 flex-col rounded-2xl border border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 px-3.5 py-3">
|
||||
<span className="size-2.5 rounded-sm" style={{ background: stage.color }} />
|
||||
<span className="font-semibold text-sm tracking-tight">{stage.label}</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-semibold text-muted-foreground text-xs">
|
||||
{applications.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex min-h-24 flex-1 flex-col gap-2.5 overflow-y-auto px-2.5 pb-3 transition-colors",
|
||||
isOver && "bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{applications.map((app) => (
|
||||
<DraggableCard key={app.id} application={app} onOpen={() => onOpen(app)} onEdit={onEdit} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableCard({
|
||||
application,
|
||||
onOpen,
|
||||
onEdit,
|
||||
}: {
|
||||
application: Application;
|
||||
onOpen: () => void;
|
||||
onEdit: (application: Application) => void;
|
||||
}) {
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: application.id });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} {...attributes} {...listeners} className={cn(isDragging && "opacity-30")}>
|
||||
<ApplicationCard application={application} onClick={onOpen} onEdit={onEdit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CheckCircleIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@reactive-resume/ui/components/sheet";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { mapCsvToApplications, parseCsv } from "../csv";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
|
||||
const MAX_IMPORT = 500;
|
||||
const SAMPLE =
|
||||
"Company,Role,Stage,Location,Salary,Source,Tags\nStripe,Frontend Engineer,applied,Remote,$180k,LinkedIn,remote;react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [text, setText] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const parsed = useMemo(() => (text.trim() ? mapCsvToApplications(parseCsv(text)) : null), [text]);
|
||||
|
||||
// The import endpoint caps a batch at 500; slice client-side and surface the overflow so the
|
||||
// user knows to split rather than getting a generic server rejection.
|
||||
const importable = parsed ? parsed.rows.slice(0, MAX_IMPORT) : [];
|
||||
const overflow = (parsed?.rows.length ?? 0) - importable.length;
|
||||
|
||||
const resetFile = () => {
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const importMutation = useMutation(
|
||||
orpc.applications.import.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.campaigns.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
|
||||
toast.success(t`Imported ${result.imported} application(s).`);
|
||||
setText("");
|
||||
resetFile();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Import failed. Check the CSV and try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const onFile = (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
file.text().then(setText);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Trans>Import from CSV</Trans>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
<Trans>
|
||||
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Salary, Source and Tags.
|
||||
</Trans>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
|
||||
<UploadSimpleIcon />
|
||||
<Trans>Upload .csv</Trans>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setText(SAMPLE)}>
|
||||
<Trans>Use sample</Trans>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(event) => onFile(event.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>CSV data</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
value={text}
|
||||
rows={8}
|
||||
placeholder="Company,Role,Stage,…"
|
||||
className="font-mono text-xs"
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{parsed && (
|
||||
<div className="rounded-lg border border-border p-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<CheckCircleIcon className="text-emerald-500" />
|
||||
<Trans>{importable.length} ready to import</Trans>
|
||||
{parsed.skipped > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
· <Trans>{parsed.skipped} skipped (missing company/role)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{overflow > 0 && (
|
||||
<p className="mt-1.5 text-amber-600 text-xs dark:text-amber-500">
|
||||
<Trans>
|
||||
Only the first {MAX_IMPORT} rows import at once — {overflow} left out. Split the file to import the
|
||||
rest.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
{parsed.recognized.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{parsed.recognized.map((field) => (
|
||||
<Badge key={field} variant="secondary" className="text-[10px]">
|
||||
{field}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{parsed.rows.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5 text-muted-foreground text-xs">
|
||||
{parsed.rows.slice(0, 4).map((row, i) => (
|
||||
<li key={`${row.company}-${i}`} className="truncate">
|
||||
{row.role} · {row.company}
|
||||
</li>
|
||||
))}
|
||||
{parsed.rows.length > 4 && <li>+{parsed.rows.length - 4} more…</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={importable.length === 0 || importMutation.isPending}
|
||||
onClick={() => importable.length > 0 && importMutation.mutate({ items: importable })}
|
||||
>
|
||||
<Trans>Import {importable.length}</Trans>
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { computeInsights } from "../insights";
|
||||
|
||||
type Props = { campaign?: string };
|
||||
|
||||
export function ApplicationInsights({ campaign }: Props) {
|
||||
const { data } = useQuery(orpc.applications.stats.queryOptions({ input: campaign ? { campaign } : {} }));
|
||||
|
||||
if (!data) return <div className="h-40 animate-pulse rounded-xl bg-muted/40" />;
|
||||
|
||||
const insights = computeInsights(data.byStage);
|
||||
const maxSource = Math.max(1, ...data.bySource.map((s) => s.count));
|
||||
|
||||
if (insights.total === 0) {
|
||||
return (
|
||||
<p className="py-16 text-center text-muted-foreground text-sm">
|
||||
<Trans>No applications yet — add a few to see your funnel and reply rates.</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-4xl flex-col gap-4 overflow-y-auto pb-6">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{campaign ? (
|
||||
<Trans>Pipeline health for campaign “{campaign}”</Trans>
|
||||
) : (
|
||||
<Trans>Pipeline health across all applications</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* stat tiles */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{insights.tiles.map((tile) => (
|
||||
<div key={tile.label} className="rounded-xl border border-border p-4">
|
||||
<div className="text-muted-foreground text-xs">{tile.label}</div>
|
||||
<div className="mt-2 font-bold text-2xl tracking-tight">{tile.value}</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">{tile.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PipelineFlow insights={insights} />
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* funnel */}
|
||||
<div className="rounded-xl border border-border p-5">
|
||||
<h3 className="font-semibold text-sm">
|
||||
<Trans>Pipeline funnel</Trans>
|
||||
</h3>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
<Trans>How far applications get, and stage-to-stage conversion</Trans>
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-3.5">
|
||||
{insights.funnel.map((stage) => (
|
||||
<div key={stage.label}>
|
||||
<div className="mb-1.5 flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
|
||||
{stage.label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<b className="text-foreground">{stage.reached}</b> · {stage.conv}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${Math.max(stage.pct, 2)}%`, background: stage.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* sources */}
|
||||
<div className="rounded-xl border border-border p-5">
|
||||
<h3 className="font-semibold text-sm">
|
||||
<Trans>Where applications come from</Trans>
|
||||
</h3>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
<Trans>Count by source</Trans>
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{data.bySource.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>No source data yet.</Trans>
|
||||
</p>
|
||||
) : (
|
||||
data.bySource.map((row) => (
|
||||
<div key={row.source} className="flex items-center gap-3 text-xs">
|
||||
<span className="w-28 shrink-0 truncate font-medium">{row.source}</span>
|
||||
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-foreground/70"
|
||||
style={{ width: `${Math.max((row.count / maxSource) * 100, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-6 text-right text-muted-foreground">{row.count}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A funnel-flow diagram (the shareable snapshot). Hand-drawn SVG with inline fills so it can be
|
||||
// exported to PNG without any library.
|
||||
function PipelineFlow({ insights }: { insights: ReturnType<typeof computeInsights> }) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const W = 720;
|
||||
const H = 220;
|
||||
const maxBarH = 150;
|
||||
const barW = 30;
|
||||
const n = insights.funnel.length;
|
||||
const slotW = W / n;
|
||||
const maxReached = Math.max(1, ...insights.funnel.map((f) => f.reached));
|
||||
|
||||
const bars = insights.funnel.map((f, i) => {
|
||||
const h = Math.max((f.reached / maxReached) * maxBarH, 3);
|
||||
const x = slotW * i + slotW / 2 - barW / 2;
|
||||
const yTop = 30 + (maxBarH - h) / 2;
|
||||
return { ...f, x, yTop, h, cx: x + barW / 2 };
|
||||
});
|
||||
|
||||
const exportPng = () => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const xml = new XMLSerializer().serializeToString(svg);
|
||||
const svg64 = `data:image/svg+xml;base64,${btoa(String.fromCharCode(...new TextEncoder().encode(xml)))}`;
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = W * 2;
|
||||
canvas.height = H * 2;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const link = document.createElement("a");
|
||||
link.download = "pipeline-flow.png";
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
toast.success(t`Exported pipeline-flow.png`);
|
||||
};
|
||||
image.src = svg64;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">
|
||||
<Trans>Where your applications went</Trans>
|
||||
</h3>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
<Trans>Full-funnel snapshot — a shareable picture of the whole search</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={exportPng}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Export PNG</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} className="mt-3 w-full" role="img" aria-label={t`Pipeline flow`}>
|
||||
{/* connecting bands */}
|
||||
{bars.slice(0, -1).map((bar, i) => {
|
||||
const next = bars[i + 1];
|
||||
if (!next) return null;
|
||||
const x1 = bar.x + barW;
|
||||
const x2 = next.x;
|
||||
return (
|
||||
<path
|
||||
key={`band-${bar.label}`}
|
||||
d={`M${x1},${bar.yTop} C${(x1 + x2) / 2},${bar.yTop} ${(x1 + x2) / 2},${next.yTop} ${x2},${next.yTop} L${x2},${next.yTop + next.h} C${(x1 + x2) / 2},${next.yTop + next.h} ${(x1 + x2) / 2},${bar.yTop + bar.h} ${x1},${bar.yTop + bar.h} Z`}
|
||||
fill={next.color}
|
||||
opacity={0.18}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* bars + labels */}
|
||||
{bars.map((bar) => (
|
||||
<g key={`bar-${bar.label}`}>
|
||||
<rect x={bar.x} y={bar.yTop} width={barW} height={bar.h} rx={5} fill={bar.color} />
|
||||
<text x={bar.cx} y={bar.yTop - 8} textAnchor="middle" fontSize={13} fontWeight={700} fill="#333">
|
||||
{bar.reached}
|
||||
</text>
|
||||
<text x={bar.cx} y={H - 22} textAnchor="middle" fontSize={11} fill="#888">
|
||||
{bar.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{insights.rejected > 0 && (
|
||||
<text x={W - 8} y={18} textAnchor="end" fontSize={11} fill="#c0392b">
|
||||
{insights.rejected} rejected
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArchiveIcon, ArrowRightIcon, TagIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Checkbox } from "@reactive-resume/ui/components/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
import { ApplicationActionsMenu } from "./application-actions-menu";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const stageOf = (status: string) => STAGES.find((s) => s.value === status);
|
||||
|
||||
type Props = {
|
||||
applications: Application[];
|
||||
onOpen: (application: Application) => void;
|
||||
onEdit: (application: Application) => void;
|
||||
};
|
||||
|
||||
export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// Drop selected rows that are no longer in the current (filtered) set, so the bulk-action bar
|
||||
// never reports a count for rows the user can't see.
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const visible = new Set(applications.map((app) => app.id));
|
||||
const next = new Set([...prev].filter((id) => visible.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [applications]);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
|
||||
const bulkUpdate = useMutation(
|
||||
orpc.applications.bulkUpdate.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t`Bulk update failed. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const bulkDelete = useMutation(
|
||||
orpc.applications.bulkDelete.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
clearSelection();
|
||||
toast.success(t`Deleted ${result.deleted} application(s).`);
|
||||
},
|
||||
onError: () => toast.error(t`Bulk delete failed. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(applications.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, pageCount - 1);
|
||||
const rows = useMemo(
|
||||
() => applications.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE),
|
||||
[applications, safePage],
|
||||
);
|
||||
|
||||
const pageIds = rows.map((row) => row.id);
|
||||
const allChecked = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
||||
const someChecked = pageIds.some((id) => selected.has(id));
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allChecked) for (const id of pageIds) next.delete(id);
|
||||
else for (const id of pageIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const ids = [...selected];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-foreground px-3 py-2 text-background">
|
||||
<span className="font-semibold text-sm">
|
||||
{selected.size} <Trans>selected</Trans>
|
||||
</span>
|
||||
<span className="mx-1 h-4 w-px bg-background/25" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="sm" variant="secondary" className="h-7">
|
||||
<ArrowRightIcon />
|
||||
<Trans>Move stage</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start">
|
||||
{STAGES.map((stage) => (
|
||||
<DropdownMenuItem key={stage.value} onClick={() => bulkUpdate.mutate({ ids, status: stage.value })}>
|
||||
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
|
||||
{stage.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AddTagPopover onAdd={(tag) => bulkUpdate.mutate({ ids, addTags: [tag] })} />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7"
|
||||
onClick={() => bulkUpdate.mutate({ ids, archived: true })}
|
||||
>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archive</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 text-destructive"
|
||||
onClick={() => bulkDelete.mutate({ ids })}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ms-auto h-7 text-background hover:bg-background/15"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<Trans>Clear</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border">
|
||||
<table className="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-muted/50 backdrop-blur">
|
||||
<tr className="[&>th]:whitespace-nowrap [&>th]:px-3 [&>th]:py-2.5 [&>th]:text-left [&>th]:font-medium [&>th]:text-muted-foreground [&>th]:text-xs [&>th]:uppercase [&>th]:tracking-wide">
|
||||
<th className="w-10">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={someChecked && !allChecked}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label={t`Select all`}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Company / Role</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Stage</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Location</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Salary</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Tags</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Source</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Last activity</Trans>
|
||||
</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((app) => {
|
||||
const stage = stageOf(app.status);
|
||||
return (
|
||||
<tr
|
||||
key={app.id}
|
||||
className={cn(
|
||||
"border-border border-t transition-colors hover:bg-muted/40",
|
||||
selected.has(app.id) && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={selected.has(app.id)}
|
||||
onCheckedChange={() => toggleOne(app.id)}
|
||||
aria-label={t`Select ${app.company}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button type="button" className="flex items-center gap-2.5 text-left" onClick={() => onOpen(app)}>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted font-bold text-[10px]">
|
||||
{getInitials(app.company)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium hover:underline">{app.role}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">{app.company}</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm" style={{ background: stage?.color }} />
|
||||
{stage?.label ?? app.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.location || "—"}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 font-medium">{app.salary || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex max-w-40 flex-wrap gap-1">
|
||||
{app.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{app.tags.length > 2 && (
|
||||
<span className="text-muted-foreground text-xs">+{app.tags.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.source || "—"}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
|
||||
{new Date(app.updatedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-1 py-2">
|
||||
<ApplicationActionsMenu application={app} onEdit={onEdit} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-muted-foreground text-sm">
|
||||
<span>
|
||||
<Trans>
|
||||
Showing {rows.length} of {applications.length}
|
||||
</Trans>
|
||||
</span>
|
||||
{pageCount > 1 && (
|
||||
<div className="ms-auto flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
|
||||
‹
|
||||
</Button>
|
||||
<span className="text-xs">
|
||||
{safePage + 1} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={safePage >= pageCount - 1}
|
||||
onClick={() => setPage(safePage + 1)}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddTagPopover({ onAdd }: { onAdd: (tag: string) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
const tag = value.trim();
|
||||
if (!tag) return;
|
||||
onAdd(tag);
|
||||
setValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="sm" variant="secondary" className="h-7">
|
||||
<TagIcon />
|
||||
<Trans>Add tag</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder={t`New tag…`}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||
/>
|
||||
<Button size="sm" onClick={submit}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapCsvToApplications, parseCsv } from "./csv";
|
||||
|
||||
describe("parseCsv", () => {
|
||||
it("parses quoted fields with commas and newlines", () => {
|
||||
const table = parseCsv('Company,Role\n"Acme, Inc.","Eng, Sr"\nBeta,"Line1\nLine2"');
|
||||
expect(table[1]).toEqual(["Acme, Inc.", "Eng, Sr"]);
|
||||
expect(table[2]).toEqual(["Beta", "Line1\nLine2"]);
|
||||
});
|
||||
|
||||
it("handles escaped quotes and CRLF", () => {
|
||||
const table = parseCsv('A,B\r\n"say ""hi""",x\r\n');
|
||||
expect(table[1]).toEqual(['say "hi"', "x"]);
|
||||
});
|
||||
|
||||
it("drops fully blank rows", () => {
|
||||
expect(parseCsv("A,B\n\n1,2\n").length).toBe(2);
|
||||
});
|
||||
|
||||
it("strips a UTF-8 BOM so the first header still maps", () => {
|
||||
const { rows } = mapCsvToApplications(parseCsv("Company,Role\nStripe,Eng"));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.company).toBe("Stripe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCsvToApplications", () => {
|
||||
it("maps aliased headers and coerces status/tags", () => {
|
||||
const csv = 'Company,Job Title,Stage,Salary,Tags\nStripe,Frontend,Interview,$180k,"remote;react"';
|
||||
const { rows, recognized } = mapCsvToApplications(parseCsv(csv));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
company: "Stripe",
|
||||
role: "Frontend",
|
||||
status: "interview",
|
||||
salary: "$180k",
|
||||
tags: ["remote", "react"],
|
||||
});
|
||||
expect(recognized).toEqual(expect.arrayContaining(["company", "role", "status", "salary", "tags"]));
|
||||
});
|
||||
|
||||
it("skips rows missing company or role and drops invalid status", () => {
|
||||
const csv = "company,role,status\nStripe,Eng,bogus\n,NoCompany,applied\nAcme,,saved";
|
||||
const { rows, skipped } = mapCsvToApplications(parseCsv(csv));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBeUndefined(); // "bogus" dropped
|
||||
expect(skipped).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
||||
import { applicationStatusSchema } from "@reactive-resume/schema/applications/data";
|
||||
|
||||
// Minimal RFC-4180-ish CSV parser: handles quoted fields, escaped quotes (""), commas and
|
||||
// newlines inside quotes, and \r\n. Enough for spreadsheet exports; not a full streaming parser.
|
||||
export function parseCsv(input: string): string[][] {
|
||||
// Strip a UTF-8 BOM (Excel prepends one) — trim() doesn't remove , so the first header
|
||||
// would otherwise become "company" and never match an alias.
|
||||
const text = input.replace(/^/, "");
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') inQuotes = true;
|
||||
else if (char === ",") {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
if (char === "\r" && text[i + 1] === "\n") i++;
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
// Flush the trailing field/row if the file didn't end in a newline.
|
||||
if (field !== "" || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows.filter((r) => r.some((cell) => cell.trim() !== ""));
|
||||
}
|
||||
|
||||
export type ParsedApplication = {
|
||||
company: string;
|
||||
role: string;
|
||||
status?: ApplicationStatus;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
source?: string;
|
||||
campaign?: string;
|
||||
notes?: string;
|
||||
sourceUrl?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
// Header aliases → canonical field. Matched case-insensitively after trimming.
|
||||
const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
|
||||
company: "company",
|
||||
employer: "company",
|
||||
organization: "company",
|
||||
role: "role",
|
||||
title: "role",
|
||||
position: "role",
|
||||
"job title": "role",
|
||||
status: "status",
|
||||
stage: "status",
|
||||
location: "location",
|
||||
salary: "salary",
|
||||
"salary range": "salary",
|
||||
compensation: "salary",
|
||||
source: "source",
|
||||
campaign: "campaign",
|
||||
notes: "notes",
|
||||
note: "notes",
|
||||
url: "sourceUrl",
|
||||
link: "sourceUrl",
|
||||
"job url": "sourceUrl",
|
||||
"job posting": "sourceUrl",
|
||||
tags: "tags",
|
||||
};
|
||||
|
||||
export type CsvMapResult = {
|
||||
rows: ParsedApplication[];
|
||||
skipped: number;
|
||||
headers: string[];
|
||||
recognized: string[];
|
||||
};
|
||||
|
||||
// Maps parsed CSV rows to application inputs using the header row. Rows missing company or role
|
||||
// are skipped (and counted). Status is coerced to a valid stage or dropped.
|
||||
export function mapCsvToApplications(table: string[][]): CsvMapResult {
|
||||
const [headerRow, ...dataRows] = table;
|
||||
if (!headerRow) return { rows: [], skipped: 0, headers: [], recognized: [] };
|
||||
|
||||
const headers = headerRow.map((h) => h.trim());
|
||||
const fieldFor = headers.map((h) => HEADER_ALIASES[h.toLowerCase()]);
|
||||
const recognized = [...new Set(fieldFor.filter((f): f is keyof ParsedApplication => !!f))];
|
||||
|
||||
const rows: ParsedApplication[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const raw of dataRows) {
|
||||
const record: Partial<ParsedApplication> = {};
|
||||
fieldFor.forEach((field, i) => {
|
||||
if (!field) return;
|
||||
const value = (raw[i] ?? "").trim();
|
||||
if (!value) return;
|
||||
if (field === "tags")
|
||||
record.tags = value
|
||||
.split(/[;,|]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
else if (field === "status") {
|
||||
const parsed = applicationStatusSchema.safeParse(value.toLowerCase());
|
||||
if (parsed.success) record.status = parsed.data;
|
||||
} else record[field] = value as never;
|
||||
});
|
||||
|
||||
if (!record.company || !record.role) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
rows.push(record as ParsedApplication);
|
||||
}
|
||||
|
||||
return { rows, skipped, headers, recognized };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { StageCount } from "./insights";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeInsights } from "./insights";
|
||||
|
||||
const byStage: StageCount[] = [
|
||||
{ status: "saved", count: 2 },
|
||||
{ status: "applied", count: 3 },
|
||||
{ status: "screening", count: 2 },
|
||||
{ status: "interview", count: 1 },
|
||||
{ status: "offer", count: 1 },
|
||||
{ status: "rejected", count: 2 },
|
||||
];
|
||||
|
||||
describe("computeInsights", () => {
|
||||
it("totals every stage including rejected", () => {
|
||||
expect(computeInsights(byStage).total).toBe(11);
|
||||
});
|
||||
|
||||
it("computes cumulative reach down the forward funnel", () => {
|
||||
const { funnel } = computeInsights(byStage);
|
||||
// saved reached = all forward stages = 2+3+2+1+1 = 9
|
||||
expect(funnel[0]?.reached).toBe(9);
|
||||
// offer reached = 1 (just the offer bucket)
|
||||
expect(funnel.at(-1)?.reached).toBe(1);
|
||||
});
|
||||
|
||||
it("derives tiles (applied past saved, interviews, offers)", () => {
|
||||
const tiles = Object.fromEntries(computeInsights(byStage).tiles.map((tile) => [tile.label, tile.value]));
|
||||
expect(tiles.Applied).toBe("7"); // everything past saved
|
||||
expect(tiles.Interviews).toBe("2"); // interview + offer
|
||||
expect(tiles.Offers).toBe("1");
|
||||
});
|
||||
|
||||
it("handles an empty pipeline without dividing by zero", () => {
|
||||
const empty = computeInsights([]);
|
||||
expect(empty.total).toBe(0);
|
||||
expect(empty.funnel[0]?.pct).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
|
||||
export type StageCount = { status: ApplicationStatus; count: number };
|
||||
|
||||
// The forward pipeline (rejected is a terminal outcome, handled separately).
|
||||
const FORWARD: ApplicationStatus[] = ["saved", "applied", "screening", "interview", "offer"];
|
||||
|
||||
export type Insights = {
|
||||
total: number;
|
||||
tiles: { label: string; value: string; sub: string }[];
|
||||
funnel: { label: string; color: string; count: number; reached: number; pct: number; conv: string }[];
|
||||
rejected: number;
|
||||
};
|
||||
|
||||
// Pure function of the raw per-stage counts, so it's trivially testable and shared by every
|
||||
// chart in the Insights view. "reached[i]" assumes the pipeline is monotonic for currently
|
||||
// active applications (an app at Interview has passed through Screening).
|
||||
export function computeInsights(byStage: StageCount[]): Insights {
|
||||
const counts = new Map<ApplicationStatus, number>(byStage.map((row) => [row.status, row.count]));
|
||||
const at = (status: ApplicationStatus) => counts.get(status) ?? 0;
|
||||
const total = byStage.reduce((sum, row) => sum + row.count, 0);
|
||||
const rejected = at("rejected");
|
||||
|
||||
// reached[i] = active apps that got at least as far as FORWARD[i].
|
||||
const reached = FORWARD.map((_, i) => FORWARD.slice(i).reduce((sum, s) => sum + at(s), 0));
|
||||
const appliedOn = reached[1] ?? 0; // everything that made it past "saved"
|
||||
|
||||
const funnel = FORWARD.map((status, i) => {
|
||||
const stage = STAGES.find((s) => s.value === status);
|
||||
const reachedCount = reached[i] ?? 0;
|
||||
const prev = i === 0 ? reachedCount : (reached[i - 1] ?? reachedCount);
|
||||
return {
|
||||
label: stage?.label ?? status,
|
||||
color: stage?.color ?? "var(--muted)",
|
||||
count: at(status),
|
||||
reached: reachedCount,
|
||||
pct: total > 0 ? Math.round((reachedCount / total) * 100) : 0,
|
||||
conv: prev > 0 ? `${Math.round((reachedCount / prev) * 100)}%` : "—",
|
||||
};
|
||||
});
|
||||
|
||||
const interviews = at("interview") + at("offer");
|
||||
const offers = at("offer");
|
||||
const responseRate = appliedOn > 0 ? Math.round(((reached[2] ?? 0) / appliedOn) * 100) : 0;
|
||||
|
||||
const tiles = [
|
||||
{ label: "Total applications", value: String(total), sub: "in this view" },
|
||||
{ label: "Applied", value: String(appliedOn), sub: "past saved" },
|
||||
{ label: "Response rate", value: `${responseRate}%`, sub: "reached screening" },
|
||||
{ label: "Interviews", value: String(interviews), sub: "interview or beyond" },
|
||||
{ label: "Offers", value: String(offers), sub: rejected > 0 ? `${rejected} rejected` : "so far" },
|
||||
];
|
||||
|
||||
return { total, tiles, funnel, rejected };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
// A single source of truth for the applications list query so the query key stays identical
|
||||
// across the board (optimistic drag), the detail panel, and the route. We always fetch archived
|
||||
// rows too and filter them per-view client-side, so "unarchive" has something to act on.
|
||||
const LIST_INPUT = { includeArchived: true } as const;
|
||||
|
||||
export const applicationsListQueryOptions = () => orpc.applications.list.queryOptions({ input: LIST_INPUT });
|
||||
export const applicationsListQueryKey = () => orpc.applications.list.queryKey({ input: LIST_INPUT });
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
|
||||
export type Application = RouterOutput["applications"]["list"][number];
|
||||
@@ -30,6 +30,7 @@ import { Route as AgentThreadIdRouteImport } from "./routes/agent/$threadId";
|
||||
import { Route as UsernameSlugRouteImport } from "./routes/$username/$slug";
|
||||
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
|
||||
import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index";
|
||||
import { Route as DashboardApplicationsIndexRouteImport } from "./routes/dashboard/applications/index";
|
||||
import { Route as BuilderResumeIdIndexRouteImport } from "./routes/builder/$resumeId/index";
|
||||
import { Route as DashboardSettingsProfileRouteImport } from "./routes/dashboard/settings/profile";
|
||||
import { Route as DashboardSettingsPreferencesRouteImport } from "./routes/dashboard/settings/preferences";
|
||||
@@ -143,6 +144,12 @@ const DashboardResumesIndexRoute = DashboardResumesIndexRouteImport.update({
|
||||
path: "/resumes/",
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any);
|
||||
const DashboardApplicationsIndexRoute =
|
||||
DashboardApplicationsIndexRouteImport.update({
|
||||
id: "/applications/",
|
||||
path: "/applications/",
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any);
|
||||
const BuilderResumeIdIndexRoute = BuilderResumeIdIndexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
@@ -218,6 +225,7 @@ export interface FileRoutesByFullPath {
|
||||
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
|
||||
"/dashboard/settings/profile": typeof DashboardSettingsProfileRoute;
|
||||
"/builder/$resumeId/": typeof BuilderResumeIdIndexRoute;
|
||||
"/dashboard/applications/": typeof DashboardApplicationsIndexRoute;
|
||||
"/dashboard/resumes/": typeof DashboardResumesIndexRoute;
|
||||
"/dashboard/settings/authentication/": typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -244,6 +252,7 @@ export interface FileRoutesByTo {
|
||||
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
|
||||
"/dashboard/settings/profile": typeof DashboardSettingsProfileRoute;
|
||||
"/builder/$resumeId": typeof BuilderResumeIdIndexRoute;
|
||||
"/dashboard/applications": typeof DashboardApplicationsIndexRoute;
|
||||
"/dashboard/resumes": typeof DashboardResumesIndexRoute;
|
||||
"/dashboard/settings/authentication": typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -276,6 +285,7 @@ export interface FileRoutesById {
|
||||
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
|
||||
"/dashboard/settings/profile": typeof DashboardSettingsProfileRoute;
|
||||
"/builder/$resumeId/": typeof BuilderResumeIdIndexRoute;
|
||||
"/dashboard/applications/": typeof DashboardApplicationsIndexRoute;
|
||||
"/dashboard/resumes/": typeof DashboardResumesIndexRoute;
|
||||
"/dashboard/settings/authentication/": typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -308,6 +318,7 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/settings/preferences"
|
||||
| "/dashboard/settings/profile"
|
||||
| "/builder/$resumeId/"
|
||||
| "/dashboard/applications/"
|
||||
| "/dashboard/resumes/"
|
||||
| "/dashboard/settings/authentication/";
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
@@ -334,6 +345,7 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/settings/preferences"
|
||||
| "/dashboard/settings/profile"
|
||||
| "/builder/$resumeId"
|
||||
| "/dashboard/applications"
|
||||
| "/dashboard/resumes"
|
||||
| "/dashboard/settings/authentication";
|
||||
id:
|
||||
@@ -365,6 +377,7 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/settings/preferences"
|
||||
| "/dashboard/settings/profile"
|
||||
| "/builder/$resumeId/"
|
||||
| "/dashboard/applications/"
|
||||
| "/dashboard/resumes/"
|
||||
| "/dashboard/settings/authentication/";
|
||||
fileRoutesById: FileRoutesById;
|
||||
@@ -528,6 +541,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DashboardResumesIndexRouteImport;
|
||||
parentRoute: typeof DashboardRouteRoute;
|
||||
};
|
||||
"/dashboard/applications/": {
|
||||
id: "/dashboard/applications/";
|
||||
path: "/applications";
|
||||
fullPath: "/dashboard/applications/";
|
||||
preLoaderRoute: typeof DashboardApplicationsIndexRouteImport;
|
||||
parentRoute: typeof DashboardRouteRoute;
|
||||
};
|
||||
"/builder/$resumeId/": {
|
||||
id: "/builder/$resumeId/";
|
||||
path: "/";
|
||||
@@ -649,6 +669,7 @@ interface DashboardRouteRouteChildren {
|
||||
DashboardSettingsJobSearchRoute: typeof DashboardSettingsJobSearchRoute;
|
||||
DashboardSettingsPreferencesRoute: typeof DashboardSettingsPreferencesRoute;
|
||||
DashboardSettingsProfileRoute: typeof DashboardSettingsProfileRoute;
|
||||
DashboardApplicationsIndexRoute: typeof DashboardApplicationsIndexRoute;
|
||||
DashboardResumesIndexRoute: typeof DashboardResumesIndexRoute;
|
||||
DashboardSettingsAuthenticationIndexRoute: typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -662,6 +683,7 @@ const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
|
||||
DashboardSettingsJobSearchRoute: DashboardSettingsJobSearchRoute,
|
||||
DashboardSettingsPreferencesRoute: DashboardSettingsPreferencesRoute,
|
||||
DashboardSettingsProfileRoute: DashboardSettingsProfileRoute,
|
||||
DashboardApplicationsIndexRoute: DashboardApplicationsIndexRoute,
|
||||
DashboardResumesIndexRoute: DashboardResumesIndexRoute,
|
||||
DashboardSettingsAuthenticationIndexRoute:
|
||||
DashboardSettingsAuthenticationIndexRoute,
|
||||
|
||||
@@ -9,9 +9,12 @@ import { DirectionProvider } from "@base-ui/react/direction-provider";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { IconContext } from "@phosphor-icons/react";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { HotkeysProvider } from "@tanstack/react-hotkeys";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
||||
import { createRootRouteWithContext, HeadContent, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Toaster } from "@reactive-resume/ui/components/sonner";
|
||||
@@ -135,6 +138,21 @@ function RootComponent() {
|
||||
<Toaster richColors position="bottom-center" />
|
||||
|
||||
{import.meta.env.DEV && <BreakpointIndicator />}
|
||||
{import.meta.env.DEV && (
|
||||
<TanStackDevtools
|
||||
config={{ position: "bottom-left" }}
|
||||
plugins={[
|
||||
{
|
||||
name: "TanStack Query",
|
||||
render: <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
{
|
||||
name: "TanStack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</PromptDialogProvider>
|
||||
</ConfirmDialogProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
ChatCircleDotsIcon,
|
||||
GearSixIcon,
|
||||
KeyIcon,
|
||||
@@ -50,6 +51,11 @@ const appSidebarItems = [
|
||||
label: msg`Resumes`,
|
||||
href: "/dashboard/resumes",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Applications`,
|
||||
href: "/dashboard/applications",
|
||||
},
|
||||
{
|
||||
icon: <ChatCircleDotsIcon />,
|
||||
label: msg`Agents`,
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { Application } from "@/features/applications/types";
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
BriefcaseIcon,
|
||||
ChartBarIcon,
|
||||
DownloadSimpleIcon,
|
||||
KanbanIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
RowsIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { ApplicationDetailSheet } from "@/features/applications/components/application-detail-sheet";
|
||||
import { ApplicationFormSheet } from "@/features/applications/components/application-form-sheet";
|
||||
import { ApplicationBoard } from "@/features/applications/components/board";
|
||||
import { ImportApplicationsSheet } from "@/features/applications/components/import-applications-sheet";
|
||||
import { ApplicationInsights } from "@/features/applications/components/insights-view";
|
||||
import { ApplicationTable } from "@/features/applications/components/table-view";
|
||||
import { applicationsListQueryOptions } from "@/features/applications/queries";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
const searchSchema = z.object({
|
||||
search: z.string().default(""),
|
||||
view: z.enum(["board", "table", "insights"]).default("board"),
|
||||
tags: z.array(z.string()).default([]),
|
||||
campaign: z.string().default(""),
|
||||
archived: z.boolean().default(false),
|
||||
});
|
||||
type Search = z.output<typeof searchSchema>;
|
||||
const defaultSearch: Search = { search: "", view: "board", tags: [], campaign: "", archived: false };
|
||||
|
||||
export const Route = createFileRoute("/dashboard/applications/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: searchSchema,
|
||||
search: { middlewares: [stripSearchParams(defaultSearch)] },
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { i18n } = useLingui();
|
||||
const { search, view, tags, campaign, archived } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Application | null>(null);
|
||||
const [selected, setSelected] = useState<Application | null>(null);
|
||||
|
||||
// Editing from the detail panel: close the panel, open the edit form on the same application.
|
||||
const startEdit = (application: Application) => {
|
||||
setSelected(null);
|
||||
setEditing(application);
|
||||
};
|
||||
|
||||
const { data: applications } = useQuery(applicationsListQueryOptions());
|
||||
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
|
||||
const { data: campaigns } = useQuery(orpc.applications.campaigns.queryOptions());
|
||||
|
||||
// Board & table hide archived; campaign/tag/search filters are applied client-side.
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (applications ?? [])
|
||||
.filter((app) => archived || !app.archived)
|
||||
.filter((app) => !campaign || app.campaign === campaign)
|
||||
.filter((app) => tags.length === 0 || tags.every((tag: string) => app.tags.includes(tag)))
|
||||
.filter((app) => !query || app.company.toLowerCase().includes(query) || app.role.toLowerCase().includes(query));
|
||||
}, [applications, search, campaign, tags, archived]);
|
||||
|
||||
const archivedCount = (applications ?? []).filter((app) => app.archived).length;
|
||||
|
||||
const isEmpty = (applications?.length ?? 0) === 0;
|
||||
|
||||
const setSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-2rem)] flex-col gap-4">
|
||||
<DashboardHeader
|
||||
icon={BriefcaseIcon}
|
||||
title={t`Applications`}
|
||||
actions={
|
||||
!isEmpty ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import CSV</Trans>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<PlusIcon />
|
||||
<Trans>Add application</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyState onAdd={() => setAddOpen(true)} onImport={() => setImportOpen(true)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<InputGroup className="w-full sm:w-56">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={search}
|
||||
placeholder={t`Search applications…`}
|
||||
onChange={(event) => setSearch({ search: event.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{(campaigns?.length ?? 0) > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Campaign</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
className="w-44"
|
||||
value={campaign || null}
|
||||
showClear
|
||||
placeholder={t`All`}
|
||||
options={(campaigns ?? []).map((c) => ({ value: c.name, label: `${c.name} (${c.count})` }))}
|
||||
onValueChange={(value) => setSearch({ campaign: value ?? "" })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(allTags?.length ?? 0) > 0 && (
|
||||
<Combobox
|
||||
multiple
|
||||
className="w-44"
|
||||
value={tags}
|
||||
placeholder={t`Filter tags`}
|
||||
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||
onValueChange={(value) => setSearch({ tags: value ?? [] })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{archivedCount > 0 && view !== "insights" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={archived ? "secondary" : "outline"}
|
||||
onClick={() => setSearch({ archived: !archived })}
|
||||
>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archived</Trans> ({archivedCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Tabs className="ms-auto" value={view}>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="board"
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "board" })} />}
|
||||
>
|
||||
<KanbanIcon />
|
||||
<span className="max-sm:sr-only">{i18n.t(msg`Board`)}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="table"
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "table" })} />}
|
||||
>
|
||||
<RowsIcon />
|
||||
<span className="max-sm:sr-only">{i18n.t(msg`Table`)}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="insights"
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "insights" })} />}
|
||||
>
|
||||
<ChartBarIcon />
|
||||
<span className="max-sm:sr-only">{i18n.t(msg`Insights`)}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{view !== "insights" && filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>No applications match your filters.</Trans>
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSearch({ search: "", tags: [], campaign: "", archived: false })}
|
||||
>
|
||||
<Trans>Clear filters</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{view === "board" && (
|
||||
<ApplicationBoard applications={filtered} onOpen={setSelected} onEdit={setEditing} />
|
||||
)}
|
||||
{view === "table" && (
|
||||
<ApplicationTable applications={filtered} onOpen={setSelected} onEdit={setEditing} />
|
||||
)}
|
||||
{view === "insights" && <ApplicationInsights campaign={campaign || undefined} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ApplicationFormSheet open={addOpen} onOpenChange={setAddOpen} />
|
||||
<ApplicationFormSheet open={!!editing} application={editing} onOpenChange={(open) => !open && setEditing(null)} />
|
||||
<ImportApplicationsSheet open={importOpen} onOpenChange={setImportOpen} />
|
||||
<ApplicationDetailSheet
|
||||
application={selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
onEdit={startEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<BriefcaseIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h2 className="font-semibold text-lg">
|
||||
<Trans>Track your first application</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Add a job you're applying to, link the resume you sent, and move it through your pipeline as things
|
||||
progress.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onAdd}>
|
||||
<PlusIcon />
|
||||
<Trans>Add application</Trans>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onImport}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import from CSV</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { lingui, linguiTransformerBabelPreset } from "@lingui/vite-plugin";
|
||||
import babel from "@rolldown/plugin-babel";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { devtools } from "@tanstack/devtools-vite";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
import viteReact, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
@@ -52,6 +53,7 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
plugins: [
|
||||
devtools(),
|
||||
tailwindcss(),
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Applications Tracker — Roadmap & Status
|
||||
|
||||
**Status legend:** ✅ done · 🟡 in progress · ⬜ not started · 🧊 deferred (intentional, revisit when needed)
|
||||
|
||||
A job-application tracker built inside Reactive Resume. Each application points at the live
|
||||
resume the user sent (`resumeId`), which is why it lives in-product rather than a generic
|
||||
tracker. Built from the claude.ai/design prototype "Applications Tracker.dc.html".
|
||||
|
||||
Owner dirs: `packages/db/src/schema/applications.ts`, `packages/schema/src/applications/`,
|
||||
`packages/api/src/features/applications/`, `apps/web/src/features/applications/`,
|
||||
`apps/web/src/routes/dashboard/applications/`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Core slice ✅ (shipped)
|
||||
|
||||
The working vertical slice: data model, CRUD, board, add/detail panels. Zero new deps.
|
||||
|
||||
- ✅ DB: `application` table (user FK cascade, `resumeId` FK set-null, JSONB `contacts`/`activity`, follow-up + AI-reservation columns). Migration `20260705090711_third_hercules`.
|
||||
- ✅ Schema: `applicationStatusSchema`, `STAGES` (value/label/color), `contactSchema`, `activityEventSchema`, `aiMetadataSchema`.
|
||||
- ✅ API: oRPC `applications.{list,getById,create,update,addNote,delete}` — `protectedProcedure`, `userId`-scoped via one `requireOwned`; `update` auto-logs stage-change activity.
|
||||
- ✅ Web: sidebar nav item; `/dashboard/applications` route (empty state ↔ board).
|
||||
- ✅ Board: dnd-kit drag across 6 fixed stages, optimistic move + rollback toast.
|
||||
- ✅ Add slide-over: manual entry + link a live Reactive Resume; native date follow-up.
|
||||
- ✅ Detail slide-over: key facts, stage stepper, linked resume, contacts, follow-up, activity timeline + add-note, archive/delete.
|
||||
- ✅ Unit test: activity-logging path (`service.test.ts`). Typecheck / boundaries / biome clean.
|
||||
|
||||
**Known small gaps to tidy in a later pass (⬜):**
|
||||
- ⬜ Extract Lingui messages (`pnpm --filter web ...` extract) so new `<Trans>`/`t` strings are translatable.
|
||||
- ⬜ Board loads all applications at once (fine for typical volumes). Add pagination/virtualization only if a user has hundreds. `// ponytail: revisit if volume grows`.
|
||||
- ⬜ Editing contacts from the Detail panel is read-only today (contacts are settable via API `update`, but there's no add/edit-contact UI). Add a small contacts editor.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Table view + Insights ✅ (shipped)
|
||||
|
||||
- ✅ **Table view** — paginated table (25/page), row selection + bulk actions (move stage, add tag, archive, delete). Added a wrapped `Checkbox` in `packages/ui`; hand-rolled table (no `@tanstack/react-table` dep). Board/Table/Insights toggle via URL `view` search param.
|
||||
- ✅ **Insights view** — stat tiles, pipeline funnel (conversion per stage), source bars, and a shareable **funnel-flow SVG** with PNG export (canvas, no chart lib — kept the zero-dep property). Server aggregation `applications.stats` (per-stage/per-source counts); funnel/tiles derived client-side via `computeInsights()` (unit-tested).
|
||||
- ✅ Tags: `tags text[]` column + filter UI + bulk "add tag" + `applications.tags` distinct list.
|
||||
- ✅ Archived-toggle so archived rows can be reached and unarchived (closes the archive loop).
|
||||
|
||||
## Phase 2 — Campaigns + import + uploads ✅ (shipped)
|
||||
|
||||
- ✅ **Campaigns** — the `campaign` text field is now first-class: set on create (with a native `<datalist>` autocomplete of existing campaigns), filter on board/table, per-campaign Insights scoping, and an `applications.campaigns` distinct-with-counts endpoint. (Kept it a text field, not a table — grouping/filtering/insights work without the join-table overhead.)
|
||||
- ✅ **CSV import** — `Import CSV` opens a slide-over: paste rows or upload a `.csv`, a zero-dep parser (`csv.ts`, unit-tested) maps aliased headers (Company/Role/Stage/Salary/Source/Tags/…), previews recognized columns + ready/skipped counts, then bulk-creates via `applications.import` (max 500, each row gets a `created` activity event). Verified end-to-end in the browser.
|
||||
- ✅ **Cover-letter upload** — `coverLetterUrl`/`coverLetterName` columns; the detail panel's Documents section attaches a PDF via the existing `orpc.storage.uploadFile`, shows a download link, and supports removal.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — AI agent integration ✅ (shipped, verified live)
|
||||
|
||||
All four `applications.ai.*` procedures are implemented (`packages/api/src/features/applications/ai.ts`), resolving the user's default provider via `aiProvidersService.getDefaultRunnable` → `getModel` → `generateText`, with tolerant JSON parsing. Rate-limited via `aiRequestRateLimit`.
|
||||
|
||||
- ✅ **Job-posting autofill** — paste a URL → server fetches + strips the page → LLM extracts company/role/location/salary/jobDescription → prefills the Add form. (`jobDescription` is now a user-editable field so it persists for match/tailor.)
|
||||
- ✅ **Resume↔job match score** — scores the linked resume vs the job description, persists `matchScore` + gaps in `aiMetadata`. Rendered as a **fit ring** in the redesigned copilot.
|
||||
- ✅ **Tailor resume** — one-shot: duplicates the linked resume, regenerates a job-tailored `summary`, links the copy to the application, logs a timeline event. (No agent/REDIS dependency.)
|
||||
- ✅ **Draft cover letter / follow-up** — generates from application + resume context; shown inline with copy/dismiss.
|
||||
- ✅ **UX**: the AI section was redesigned into an **"Application Copilot"** module — a resume-fit ring (band-colored) as the signature element + a capability menu (icon + title + description) instead of plain buttons.
|
||||
|
||||
Verified live against a real OpenAI provider (autofill, match score, tailor resume).
|
||||
|
||||
### Extra polish shipped alongside (from review + user requests)
|
||||
- ✅ **Edit an application** — the Add sheet became `ApplicationFormSheet`, edit-capable; reachable from the detail-panel "Edit" button and the context menu.
|
||||
- ✅ **Context menus** — kebab "⋯" on board cards (hover) and table rows: Edit / Move to ▸ / Archive / Delete (`application-actions-menu.tsx`).
|
||||
- ✅ Review fixes: bulk stage-moves log activity; single-statement note append; "Mark rejected" + "no results" states; CSV BOM strip + 500-row cap + file-input reset.
|
||||
|
||||
### 🧊 Deferred
|
||||
- **Cover-letter upload** — reverted. The shared storage `uploadFile` forces a `.jpeg` key for every file, so PDFs are stored/served wrong on the local backend. Needs storage-layer work (extension-preserving keys + lifecycle deletion) before re-adding. Link-to-Reactive-Resume remains the document feature.
|
||||
|
||||
---
|
||||
|
||||
## (historical) Phase 3 plan — reservations in place, no model wired yet
|
||||
|
||||
Reservations already shipped: schema fields (`sourceUrl`, `jobDescription`, `matchScore`,
|
||||
`aiMetadata`) and stubbed `applications.ai.*` procedures that throw `NOT_IMPLEMENTED`
|
||||
(`packages/api/src/features/applications/ai.ts`). Each stub below just needs its handler
|
||||
implemented against `@reactive-resume/ai` and the existing agent/thread tables
|
||||
(`agentThread`, `agentAction` in `packages/db/src/schema/agent.ts`).
|
||||
|
||||
- ⬜ **Job-posting autofill** (`applications.ai.autofill`) — fetch `sourceUrl` (or accept pasted text) → LLM extracts company/role/location/salary/jobDescription → return a prefill object for the Add form; persist raw extraction in `aiMetadata`. UI: enable the disabled "Auto-fill" button in `add-application-sheet.tsx`.
|
||||
- ⬜ **Resume ↔ job match score** (`applications.ai.matchScore`) — compare the linked resume's data against `jobDescription` → write `matchScore` (0–100) + a gap list into `aiMetadata`. UI: surface a score badge on the card + a gaps section in the Detail panel.
|
||||
- ⬜ **Tailor resume for this job** (`applications.ai.tailorResume`) — spawn an agent thread that duplicates the linked resume and edits it for the posting (reuse the agent JSON-patch pipeline); link the new `resumeId` back to the application and log a timeline event.
|
||||
- ⬜ **Cover-letter / follow-up drafting** (`applications.ai.draftMessage`) — generate a cover letter or recruiter follow-up from application + resume + contact context. UI: the disabled "AI actions" block in `application-detail-sheet.tsx`.
|
||||
- ⬜ **Rate-limiting & provider check** — reuse `aiRequestRateLimit` (already exists) and gate on a configured AI provider (`aiProviders` table) before calling any of the above.
|
||||
|
||||
**Suggested order:** autofill (highest user value, self-contained) → match score → draft
|
||||
message → tailor resume (most complex, depends on the agent pipeline).
|
||||
|
||||
---
|
||||
|
||||
## Verification (each phase)
|
||||
- `pnpm --filter @reactive-resume/api test` + `typecheck`, `pnpm --filter web typecheck`, `pnpm exec turbo boundaries`, `pnpm check`.
|
||||
- DB changes: `dotenvx run -f .env.local -- pnpm db:generate` → review SQL → `pnpm db:migrate`.
|
||||
- Manual: `dotenvx run -f .env.local -- pnpm dev` (:3000) → exercise the new surface end-to-end.
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE "application" (
|
||||
"id" text PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"company" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"location" text,
|
||||
"salary" text,
|
||||
"status" text DEFAULT 'saved' NOT NULL,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"resume_id" text,
|
||||
"source" text,
|
||||
"source_url" text,
|
||||
"job_description" text,
|
||||
"match_score" integer,
|
||||
"ai_metadata" jsonb,
|
||||
"campaign" text,
|
||||
"notes" text,
|
||||
"follow_up_at" timestamp with time zone,
|
||||
"follow_up_note" text,
|
||||
"contacts" jsonb DEFAULT '[]' NOT NULL,
|
||||
"activity" jsonb DEFAULT '[]' NOT NULL,
|
||||
"applied_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "application_user_id_index" ON "application" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "application_user_id_updated_at_index" ON "application" ("user_id","updated_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_resume_id_resume_id_fkey" FOREIGN KEY ("resume_id") REFERENCES "resume"("id") ON DELETE SET NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "tags" text[] DEFAULT '{}'::text[] NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "application" ADD COLUMN "cover_letter_url" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "cover_letter_name" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "application" DROP COLUMN "cover_letter_url";--> statement-breakpoint
|
||||
ALTER TABLE "application" DROP COLUMN "cover_letter_name";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import z from "zod";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import {
|
||||
activityEventSchema,
|
||||
aiMetadataSchema,
|
||||
applicationStatusSchema,
|
||||
contactSchema,
|
||||
} from "@reactive-resume/schema/applications/data";
|
||||
|
||||
const applicationSchema = createSelectSchema(schema.application, {
|
||||
id: z.string().describe("The ID of the application."),
|
||||
company: z.string().trim().min(1).describe("The company applied to."),
|
||||
role: z.string().trim().min(1).describe("The role / job title."),
|
||||
location: z.string().trim().nullable(),
|
||||
salary: z.string().trim().nullable(),
|
||||
status: applicationStatusSchema.describe("The current pipeline stage."),
|
||||
archived: z.boolean(),
|
||||
resumeId: z.string().nullable().describe("The linked Reactive Resume, if any."),
|
||||
source: z.string().trim().nullable(),
|
||||
sourceUrl: z.string().trim().nullable(),
|
||||
jobDescription: z.string().nullable(),
|
||||
matchScore: z.number().int().min(0).max(100).nullable(),
|
||||
aiMetadata: aiMetadataSchema.nullable(),
|
||||
campaign: z.string().trim().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
followUpAt: z.date().nullable(),
|
||||
followUpNote: z.string().trim().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
contacts: z.array(contactSchema),
|
||||
activity: z.array(activityEventSchema),
|
||||
appliedAt: z.date(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
// Fields a client is allowed to set/change. `status`, `activity` and AI-owned fields are
|
||||
// excluded here — status changes go through the auto-logging update path, activity through
|
||||
// addNote, and AI fields are written only by the (reserved) AI procedures.
|
||||
const editableSchema = applicationSchema.pick({
|
||||
company: true,
|
||||
role: true,
|
||||
location: true,
|
||||
salary: true,
|
||||
source: true,
|
||||
sourceUrl: true,
|
||||
jobDescription: true,
|
||||
campaign: true,
|
||||
notes: true,
|
||||
followUpAt: true,
|
||||
followUpNote: true,
|
||||
contacts: true,
|
||||
resumeId: true,
|
||||
tags: true,
|
||||
});
|
||||
|
||||
const createInputSchema = editableSchema.partial().extend({
|
||||
company: applicationSchema.shape.company,
|
||||
role: applicationSchema.shape.role,
|
||||
status: applicationStatusSchema.optional(),
|
||||
});
|
||||
|
||||
export const applicationDto = {
|
||||
list: {
|
||||
input: z
|
||||
.object({
|
||||
status: applicationStatusSchema.optional(),
|
||||
campaign: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
includeArchived: z.boolean().optional().default(false),
|
||||
})
|
||||
.optional()
|
||||
.default({ includeArchived: false }),
|
||||
output: z.array(applicationSchema.omit({ userId: true })),
|
||||
},
|
||||
|
||||
getById: {
|
||||
input: applicationSchema.pick({ id: true }),
|
||||
output: applicationSchema.omit({ userId: true }),
|
||||
},
|
||||
|
||||
create: {
|
||||
input: createInputSchema,
|
||||
output: z.string().describe("The ID of the created application."),
|
||||
},
|
||||
|
||||
// Bulk create from a CSV import. Each item is a create input; company/role required per item.
|
||||
import: {
|
||||
input: z.object({ items: z.array(createInputSchema).min(1).max(500) }),
|
||||
output: z.object({ imported: z.number() }),
|
||||
},
|
||||
|
||||
update: {
|
||||
input: editableSchema
|
||||
.partial()
|
||||
.extend({ id: z.string(), status: applicationStatusSchema.optional(), archived: z.boolean().optional() }),
|
||||
output: applicationSchema.omit({ userId: true }),
|
||||
},
|
||||
|
||||
addNote: {
|
||||
input: z.object({ id: z.string(), text: z.string().trim().min(1) }),
|
||||
output: applicationSchema.omit({ userId: true }),
|
||||
},
|
||||
|
||||
delete: {
|
||||
input: applicationSchema.pick({ id: true }),
|
||||
output: z.void(),
|
||||
},
|
||||
|
||||
// Table bulk actions: move stage, archive/unarchive, add tags across a selection.
|
||||
bulkUpdate: {
|
||||
input: z.object({
|
||||
ids: z.array(z.string()).min(1),
|
||||
status: applicationStatusSchema.optional(),
|
||||
archived: z.boolean().optional(),
|
||||
addTags: z.array(z.string()).optional(),
|
||||
}),
|
||||
output: z.object({ updated: z.number() }),
|
||||
},
|
||||
|
||||
bulkDelete: {
|
||||
input: z.object({ ids: z.array(z.string()).min(1) }),
|
||||
output: z.object({ deleted: z.number() }),
|
||||
},
|
||||
|
||||
// Aggregates for the Insights view. Everything else (funnel, sankey, tiles) is derived
|
||||
// client-side from these raw counts via computeInsights().
|
||||
stats: {
|
||||
input: z.object({ campaign: z.string().optional() }).optional(),
|
||||
output: z.object({
|
||||
total: z.number(),
|
||||
byStage: z.array(z.object({ status: applicationStatusSchema, count: z.number() })),
|
||||
bySource: z.array(z.object({ source: z.string(), count: z.number() })),
|
||||
}),
|
||||
},
|
||||
|
||||
// Distinct campaign names for the sidebar/filter (Phase 2 uses the same shape).
|
||||
campaigns: {
|
||||
input: z.void(),
|
||||
output: z.array(z.object({ name: z.string(), count: z.number() })),
|
||||
},
|
||||
|
||||
tags: {
|
||||
input: z.void(),
|
||||
output: z.array(z.string()),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { generateText } from "ai";
|
||||
import z from "zod";
|
||||
import { generateId, slugify } from "@reactive-resume/utils/string";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { getModel } from "../ai/service";
|
||||
import { aiProvidersService } from "../ai-providers/service";
|
||||
import { resumeService } from "../resume/service";
|
||||
import { applicationService } from "./service";
|
||||
|
||||
const reserved = { tags: ["Applications", "AI"] } as const;
|
||||
|
||||
// Resolve the user's default (tested + enabled) AI provider into a ready model instance.
|
||||
async function resolveModel(userId: string) {
|
||||
const provider = await aiProvidersService.getDefaultRunnable({ userId });
|
||||
if (!provider) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "No AI provider is configured. Add one in Settings → Integrations to use AI features.",
|
||||
});
|
||||
}
|
||||
return getModel({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
...(provider.baseURL ? { baseURL: provider.baseURL } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// generateText + tolerant JSON extraction + Zod validation. Mirrors the resume-analysis pattern
|
||||
// (the SDK's generateObject isn't wired for every provider here, so we parse defensively).
|
||||
async function generateJson<T>(model: Awaited<ReturnType<typeof resolveModel>>, prompt: string, schema: z.ZodType<T>) {
|
||||
const { text } = await generateText({ model, messages: [{ role: "user", content: prompt }] });
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
const candidate = fenced?.[1] ?? text;
|
||||
const start = candidate.indexOf("{");
|
||||
const end = candidate.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end < start) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "The AI response could not be parsed." });
|
||||
}
|
||||
return schema.parse(JSON.parse(candidate.slice(start, end + 1)));
|
||||
}
|
||||
|
||||
async function generatePlainText(model: Awaited<ReturnType<typeof resolveModel>>, prompt: string) {
|
||||
const { text } = await generateText({ model, messages: [{ role: "user", content: prompt }] });
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
// Best-effort fetch + strip of a job posting page. http(s) only, size/time capped.
|
||||
async function fetchJobPostingText(url: string): Promise<string> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The job posting URL is invalid." });
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Only http(s) job posting URLs are supported." });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { "user-agent": "ReactiveResumeBot/1.0" },
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new ORPCError("BAD_REQUEST", { message: `Couldn't fetch the posting (HTTP ${response.status}).` });
|
||||
const html = (await response.text()).slice(0, 200_000);
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 8_000);
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError) throw error;
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Couldn't read the job posting. Paste the description instead." });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const autofillOutput = z.object({
|
||||
company: z.string(),
|
||||
role: z.string(),
|
||||
location: z.string(),
|
||||
salary: z.string(),
|
||||
jobDescription: z.string(),
|
||||
});
|
||||
|
||||
// Tolerant of LLM variance: clamp the score, cap the lists by slicing rather than rejecting.
|
||||
const matchScoreOutput = z.object({
|
||||
score: z.coerce
|
||||
.number()
|
||||
.catch(0)
|
||||
.transform((n) => Math.max(0, Math.min(100, Math.round(n)))),
|
||||
gaps: z
|
||||
.array(z.string())
|
||||
.catch([])
|
||||
.transform((a) => a.slice(0, 8)),
|
||||
strengths: z
|
||||
.array(z.string())
|
||||
.catch([])
|
||||
.transform((a) => a.slice(0, 8)),
|
||||
});
|
||||
|
||||
export const aiRouter = {
|
||||
// Extract structured fields from a pasted job description or a posting URL.
|
||||
autofill: protectedProcedure
|
||||
.route({ method: "POST", path: "/applications/ai/autofill", operationId: "aiAutofillApplication", ...reserved })
|
||||
.input(z.object({ sourceUrl: z.string().optional(), jobDescription: z.string().optional() }))
|
||||
.use(aiRequestRateLimit)
|
||||
.output(autofillOutput)
|
||||
.handler(async ({ context, input }) => {
|
||||
const model = await resolveModel(context.user.id);
|
||||
const posting =
|
||||
input.jobDescription?.trim() || (input.sourceUrl ? await fetchJobPostingText(input.sourceUrl) : "");
|
||||
if (!posting) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Provide a job posting URL or paste the description." });
|
||||
}
|
||||
|
||||
return generateJson(
|
||||
model,
|
||||
`Extract the following fields from this job posting. Return ONLY JSON with keys company, role, location, salary, jobDescription. Use an empty string for anything not stated. "jobDescription" should be a concise 1–2 paragraph plain-text summary of the responsibilities and requirements.\n\nJOB POSTING:\n${posting}`,
|
||||
autofillOutput,
|
||||
);
|
||||
}),
|
||||
|
||||
// Score the linked resume against the application's job description.
|
||||
matchScore: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications/{id}/ai/match-score",
|
||||
operationId: "aiApplicationMatchScore",
|
||||
...reserved,
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.use(aiRequestRateLimit)
|
||||
.output(matchScoreOutput)
|
||||
.handler(async ({ context, input }) => {
|
||||
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
if (!application.resumeId)
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Link a resume to this application first." });
|
||||
if (!application.jobDescription) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Add a job description (via Auto-fill or Edit) first." });
|
||||
}
|
||||
|
||||
const [model, resume] = await Promise.all([
|
||||
resolveModel(context.user.id),
|
||||
resumeService.getById({ id: application.resumeId, userId: context.user.id }),
|
||||
]);
|
||||
|
||||
const result = await generateJson(
|
||||
model,
|
||||
`Compare this resume against the job description. Return ONLY JSON with keys score (integer 0-100 fit), gaps (array of short missing-qualification strings), strengths (array of short matching-strength strings).\n\nRESUME:\n${JSON.stringify(resume.data)}\n\nJOB DESCRIPTION:\n${application.jobDescription}`,
|
||||
matchScoreOutput,
|
||||
);
|
||||
|
||||
await applicationService.setAiResult({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
matchScore: result.score,
|
||||
aiMetadata: { matchScore: result },
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
// Generate a cover letter or recruiter follow-up from the application + resume context.
|
||||
draftMessage: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications/{id}/ai/draft-message",
|
||||
operationId: "aiDraftApplicationMessage",
|
||||
...reserved,
|
||||
})
|
||||
.input(z.object({ id: z.string(), kind: z.enum(["cover-letter", "follow-up"]) }))
|
||||
.use(aiRequestRateLimit)
|
||||
.output(z.object({ text: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
const model = await resolveModel(context.user.id);
|
||||
const resume = application.resumeId
|
||||
? await resumeService.getById({ id: application.resumeId, userId: context.user.id }).catch(() => null)
|
||||
: null;
|
||||
|
||||
const context_ = `ROLE: ${application.role} at ${application.company}${application.location ? ` (${application.location})` : ""}\n${application.jobDescription ? `JOB DESCRIPTION:\n${application.jobDescription}\n` : ""}${resume ? `CANDIDATE RESUME:\n${JSON.stringify(resume.data)}` : ""}`;
|
||||
|
||||
const prompt =
|
||||
input.kind === "cover-letter"
|
||||
? `Write a concise, specific cover letter (250-350 words, no placeholders like [Name]) for this application, drawing on the resume. Return only the letter text.\n\n${context_}`
|
||||
: `Write a short, polite follow-up message (80-120 words) to a recruiter checking in on this application. Warm but not pushy. Return only the message text.\n\n${context_}`;
|
||||
|
||||
return { text: await generatePlainText(model, prompt) };
|
||||
}),
|
||||
|
||||
// Create a tailored copy of the linked resume (job-specific summary) and link it to the application.
|
||||
tailorResume: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications/{id}/ai/tailor-resume",
|
||||
operationId: "aiTailorResumeForApplication",
|
||||
...reserved,
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.use(aiRequestRateLimit)
|
||||
.output(z.object({ resumeId: z.string(), name: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
if (!application.resumeId)
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Link a resume to this application first." });
|
||||
if (!application.jobDescription) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Add a job description (via Auto-fill or Edit) first." });
|
||||
}
|
||||
|
||||
const [model, resume] = await Promise.all([
|
||||
resolveModel(context.user.id),
|
||||
resumeService.getById({ id: application.resumeId, userId: context.user.id }),
|
||||
]);
|
||||
|
||||
const { summary } = await generateJson(
|
||||
model,
|
||||
`Rewrite this candidate's professional summary to target the job below. Return ONLY JSON { "summary": "<one to two sentence HTML paragraph, e.g. <p>…</p>>" }. Keep it truthful to the resume.\n\nRESUME:\n${JSON.stringify(resume.data)}\n\nJOB:\n${application.role} at ${application.company}\n${application.jobDescription}`,
|
||||
z.object({ summary: z.string() }),
|
||||
);
|
||||
|
||||
const name = `Tailored — ${application.company} · ${application.role}`.slice(0, 60);
|
||||
const tailoredData = { ...resume.data, summary: { ...resume.data.summary, content: summary } };
|
||||
|
||||
const newResumeId = await resumeService.create({
|
||||
userId: context.user.id,
|
||||
name,
|
||||
slug: `${slugify(name)}-${generateId().slice(0, 6)}`,
|
||||
tags: [...resume.tags, "tailored"],
|
||||
data: tailoredData,
|
||||
locale: context.locale,
|
||||
});
|
||||
|
||||
// Point the application at the tailored copy and log it on the timeline.
|
||||
await applicationService.update({ id: input.id, userId: context.user.id, resumeId: newResumeId });
|
||||
await applicationService.addNote({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
text: `AI tailored a resume: ${name}`,
|
||||
});
|
||||
|
||||
return { resumeId: newResumeId, name };
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { applicationDto } from "../../dto/application";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { applicationService } from "./service";
|
||||
|
||||
export const crudRouter = {
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/applications",
|
||||
tags: ["Applications"],
|
||||
operationId: "listApplications",
|
||||
summary: "List job applications",
|
||||
description:
|
||||
"Returns all job applications belonging to the authenticated user, most recently updated first. Archived applications are excluded unless includeArchived is set. Optionally filter by pipeline stage. Requires authentication.",
|
||||
successDescription: "A list of the user's job applications.",
|
||||
})
|
||||
.input(applicationDto.list.input)
|
||||
.output(applicationDto.list.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.list({
|
||||
userId: context.user.id,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.campaign ? { campaign: input.campaign } : {}),
|
||||
...(input.tags ? { tags: input.tags } : {}),
|
||||
includeArchived: input.includeArchived,
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/applications/{id}",
|
||||
tags: ["Applications"],
|
||||
operationId: "getApplication",
|
||||
summary: "Get application by ID",
|
||||
description:
|
||||
"Returns a single job application with its full detail (contacts, activity timeline, linked resume). Only applications belonging to the authenticated user can be retrieved. Requires authentication.",
|
||||
successDescription: "The job application.",
|
||||
})
|
||||
.input(applicationDto.getById.input)
|
||||
.output(applicationDto.getById.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications",
|
||||
tags: ["Applications"],
|
||||
operationId: "createApplication",
|
||||
summary: "Create a job application",
|
||||
description:
|
||||
"Creates a new job application in the pipeline. Company and role are required; all other fields (stage, location, salary, source, linked resume, follow-up, notes, contacts) are optional. Requires authentication.",
|
||||
successDescription: "The ID of the newly created application.",
|
||||
})
|
||||
.input(applicationDto.create.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.create.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.create({ userId: context.user.id, ...input });
|
||||
}),
|
||||
|
||||
import: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications/import",
|
||||
tags: ["Applications"],
|
||||
operationId: "importApplications",
|
||||
summary: "Bulk import applications",
|
||||
description:
|
||||
"Creates many applications at once from a parsed CSV. Each item requires company and role. Returns the number imported. Requires authentication.",
|
||||
successDescription: "The number of applications imported.",
|
||||
})
|
||||
.input(applicationDto.import.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.import.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.importMany({ userId: context.user.id, items: input.items });
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/applications/{id}",
|
||||
tags: ["Applications"],
|
||||
operationId: "updateApplication",
|
||||
summary: "Update a job application",
|
||||
description:
|
||||
"Updates one or more fields of an application, including moving it to a different pipeline stage or archiving it. Moving stages automatically appends an entry to the activity timeline. Only provided fields are changed. Requires authentication.",
|
||||
successDescription: "The updated application.",
|
||||
})
|
||||
.input(applicationDto.update.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.update.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.update({ userId: context.user.id, ...input });
|
||||
}),
|
||||
|
||||
addNote: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications/{id}/notes",
|
||||
tags: ["Applications"],
|
||||
operationId: "addApplicationNote",
|
||||
summary: "Log a note on the timeline",
|
||||
description: "Appends a free-text note to the application's activity timeline. Requires authentication.",
|
||||
successDescription: "The updated application.",
|
||||
})
|
||||
.input(applicationDto.addNote.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.addNote.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text });
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/applications/{id}",
|
||||
tags: ["Applications"],
|
||||
operationId: "deleteApplication",
|
||||
summary: "Delete a job application",
|
||||
description: "Permanently deletes a job application. Requires authentication.",
|
||||
successDescription: "The application was deleted successfully.",
|
||||
})
|
||||
.input(applicationDto.delete.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.delete.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
bulkUpdate: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications/bulk-update",
|
||||
tags: ["Applications"],
|
||||
operationId: "bulkUpdateApplications",
|
||||
summary: "Bulk update applications",
|
||||
description:
|
||||
"Applies the same change (move stage, archive/unarchive, add tags) to multiple applications at once. Requires authentication.",
|
||||
successDescription: "The number of applications updated.",
|
||||
})
|
||||
.input(applicationDto.bulkUpdate.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.bulkUpdate.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.bulkUpdate({ userId: context.user.id, ...input });
|
||||
}),
|
||||
|
||||
bulkDelete: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/applications/bulk-delete",
|
||||
tags: ["Applications"],
|
||||
operationId: "bulkDeleteApplications",
|
||||
summary: "Bulk delete applications",
|
||||
description: "Permanently deletes multiple applications at once. Requires authentication.",
|
||||
successDescription: "The number of applications deleted.",
|
||||
})
|
||||
.input(applicationDto.bulkDelete.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.bulkDelete.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.bulkDelete({ userId: context.user.id, ids: input.ids });
|
||||
}),
|
||||
|
||||
stats: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/applications/stats",
|
||||
tags: ["Applications"],
|
||||
operationId: "getApplicationStats",
|
||||
summary: "Application pipeline stats",
|
||||
description:
|
||||
"Returns aggregate counts (per stage, per source) for the Insights view. Optionally scoped to a campaign. Requires authentication.",
|
||||
successDescription: "Aggregate application counts.",
|
||||
})
|
||||
.input(applicationDto.stats.input)
|
||||
.output(applicationDto.stats.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.stats({
|
||||
userId: context.user.id,
|
||||
...(input?.campaign ? { campaign: input.campaign } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
campaigns: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/applications/campaigns",
|
||||
tags: ["Applications"],
|
||||
operationId: "listApplicationCampaigns",
|
||||
summary: "List campaigns",
|
||||
description: "Returns distinct campaign names with their application counts. Requires authentication.",
|
||||
successDescription: "Distinct campaigns with counts.",
|
||||
})
|
||||
.output(applicationDto.campaigns.output)
|
||||
.handler(async ({ context }) => {
|
||||
return applicationService.campaigns({ userId: context.user.id });
|
||||
}),
|
||||
|
||||
tags: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/applications/tags",
|
||||
tags: ["Applications"],
|
||||
operationId: "listApplicationTags",
|
||||
summary: "List application tags",
|
||||
description: "Returns the distinct tags used across the user's applications. Requires authentication.",
|
||||
successDescription: "Distinct tags.",
|
||||
})
|
||||
.output(applicationDto.tags.output)
|
||||
.handler(async ({ context }) => {
|
||||
return applicationService.listTags({ userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { aiRouter } from "./ai";
|
||||
import { crudRouter } from "./crud";
|
||||
|
||||
export const applicationsRouter = {
|
||||
list: crudRouter.list,
|
||||
getById: crudRouter.getById,
|
||||
create: crudRouter.create,
|
||||
import: crudRouter.import,
|
||||
update: crudRouter.update,
|
||||
addNote: crudRouter.addNote,
|
||||
delete: crudRouter.delete,
|
||||
bulkUpdate: crudRouter.bulkUpdate,
|
||||
bulkDelete: crudRouter.bulkDelete,
|
||||
stats: crudRouter.stats,
|
||||
campaigns: crudRouter.campaigns,
|
||||
tags: crudRouter.tags,
|
||||
ai: aiRouter,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the DB layer; the logic under test is the activity-timeline bookkeeping, not SQL.
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({
|
||||
application: { id: "id", userId: "user_id", status: "status", updatedAt: "updated_at" },
|
||||
}));
|
||||
vi.mock("drizzle-orm", () => ({ and: (...a: unknown[]) => a, desc: (x: unknown) => x, eq: (...a: unknown[]) => a }));
|
||||
|
||||
const { applicationService } = await import("./service");
|
||||
|
||||
const existing = {
|
||||
id: "app-1",
|
||||
userId: "user-1",
|
||||
company: "Stripe",
|
||||
role: "Engineer",
|
||||
status: "saved" as const,
|
||||
activity: [{ id: "e0", type: "created" as const, text: "Added to Saved", at: new Date() }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.select.mockReset();
|
||||
dbMock.insert.mockReset();
|
||||
dbMock.update.mockReset();
|
||||
// requireOwned: db.select().from().where() resolves to [existing]
|
||||
dbMock.select.mockReturnValue({ from: () => ({ where: () => Promise.resolve([{ ...existing }]) }) });
|
||||
});
|
||||
|
||||
describe("applicationService.create", () => {
|
||||
it("seeds a 'created' activity event", async () => {
|
||||
const values = vi.fn(() => Promise.resolve());
|
||||
dbMock.insert.mockReturnValue({ values });
|
||||
|
||||
await applicationService.create({ userId: "user-1", company: "Stripe", role: "Engineer", status: "applied" });
|
||||
|
||||
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string }[] }]];
|
||||
expect(inserted.activity).toHaveLength(1);
|
||||
expect(inserted.activity.at(0)?.type).toBe("created");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applicationService.update", () => {
|
||||
const captureSet = () => {
|
||||
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
|
||||
dbMock.update.mockReturnValue({ set });
|
||||
return set;
|
||||
};
|
||||
|
||||
it("appends a 'stage' event when the status changes", async () => {
|
||||
const set = captureSet();
|
||||
await applicationService.update({ id: "app-1", userId: "user-1", status: "applied" });
|
||||
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: { type: string }[] }]];
|
||||
expect(arg.activity).toHaveLength(2);
|
||||
expect(arg.activity.at(-1)?.type).toBe("stage");
|
||||
});
|
||||
|
||||
it("does not append an event when the status is unchanged", async () => {
|
||||
const set = captureSet();
|
||||
await applicationService.update({ id: "app-1", userId: "user-1", notes: "hello" });
|
||||
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: unknown[] }]];
|
||||
expect(arg.activity).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, arrayContains, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
|
||||
const stageLabel = (status: ApplicationStatus) => STAGES.find((s) => s.value === status)?.label ?? status;
|
||||
|
||||
function activityEvent(type: ActivityEvent["type"], text: string): ActivityEvent {
|
||||
return { id: generateId(), type, text, at: new Date() };
|
||||
}
|
||||
|
||||
// Editable fields shared by create/update. Kept explicit so Drizzle's typed insert/update
|
||||
// checks catch mistakes; `status`/`activity` are handled separately (auto-logging).
|
||||
// `| undefined` is explicit throughout because the DTO layer (zod `.partial()`) produces
|
||||
// `T | undefined` and the repo compiles with exactOptionalPropertyTypes.
|
||||
type EditableFields = {
|
||||
company?: string | undefined;
|
||||
role?: string | undefined;
|
||||
location?: string | null | undefined;
|
||||
salary?: string | null | undefined;
|
||||
source?: string | null | undefined;
|
||||
sourceUrl?: string | null | undefined;
|
||||
jobDescription?: string | null | undefined;
|
||||
campaign?: string | null | undefined;
|
||||
notes?: string | null | undefined;
|
||||
followUpAt?: Date | null | undefined;
|
||||
followUpNote?: string | null | undefined;
|
||||
contacts?: Contact[] | undefined;
|
||||
resumeId?: string | null | undefined;
|
||||
tags?: string[] | undefined;
|
||||
};
|
||||
|
||||
// All reads/writes filter on userId — the single ownership guard every route funnels through.
|
||||
async function requireOwned(id: string, userId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)));
|
||||
if (!row) throw new ORPCError("NOT_FOUND");
|
||||
return row;
|
||||
}
|
||||
|
||||
const stripUserId = <T extends { userId: string }>(row: T) => {
|
||||
const { userId: _userId, ...rest } = row;
|
||||
return rest;
|
||||
};
|
||||
|
||||
export const applicationService = {
|
||||
list: async (input: {
|
||||
userId: string;
|
||||
status?: ApplicationStatus;
|
||||
campaign?: string;
|
||||
tags?: string[];
|
||||
includeArchived?: boolean;
|
||||
}) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.application.userId, input.userId),
|
||||
input.status ? eq(schema.application.status, input.status) : undefined,
|
||||
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
|
||||
input.tags && input.tags.length > 0 ? arrayContains(schema.application.tags, input.tags) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.application.updatedAt));
|
||||
|
||||
return rows.filter((row) => input.includeArchived || !row.archived).map(stripUserId);
|
||||
},
|
||||
|
||||
getById: async (input: { id: string; userId: string }) => {
|
||||
return stripUserId(await requireOwned(input.id, input.userId));
|
||||
},
|
||||
|
||||
create: async (
|
||||
input: EditableFields & { userId: string; company: string; role: string; status?: ApplicationStatus | undefined },
|
||||
) => {
|
||||
const { userId, status, ...fields } = input;
|
||||
const id = generateId();
|
||||
|
||||
await db.insert(schema.application).values({
|
||||
id,
|
||||
userId,
|
||||
status: status ?? "saved",
|
||||
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
|
||||
...fields,
|
||||
});
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
importMany: async (input: {
|
||||
userId: string;
|
||||
items: (EditableFields & { company: string; role: string; status?: ApplicationStatus | undefined })[];
|
||||
}) => {
|
||||
if (input.items.length === 0) return { imported: 0 };
|
||||
|
||||
const values = input.items.map(({ status, ...fields }) => ({
|
||||
id: generateId(),
|
||||
userId: input.userId,
|
||||
status: status ?? ("saved" as ApplicationStatus),
|
||||
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
|
||||
...fields,
|
||||
}));
|
||||
|
||||
const rows = await db.insert(schema.application).values(values).returning({ id: schema.application.id });
|
||||
return { imported: rows.length };
|
||||
},
|
||||
|
||||
update: async (
|
||||
input: EditableFields & {
|
||||
id: string;
|
||||
userId: string;
|
||||
status?: ApplicationStatus | undefined;
|
||||
archived?: boolean | undefined;
|
||||
},
|
||||
) => {
|
||||
const existing = await requireOwned(input.id, input.userId);
|
||||
|
||||
const { id, userId, status, archived, ...fields } = input;
|
||||
|
||||
// Auto-log a timeline event when the stage actually changes.
|
||||
const activity =
|
||||
status && status !== existing.status
|
||||
? [...existing.activity, activityEvent("stage", `Moved to ${stageLabel(status)}`)]
|
||||
: existing.activity;
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.application)
|
||||
.set({
|
||||
...fields,
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(archived !== undefined ? { archived } : {}),
|
||||
activity,
|
||||
})
|
||||
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
return stripUserId(updated);
|
||||
},
|
||||
|
||||
// Persist AI-owned enrichment (match score + freeform metadata). Separate from the editable
|
||||
// update path so these fields are only ever written by the AI procedures.
|
||||
setAiResult: async (input: {
|
||||
id: string;
|
||||
userId: string;
|
||||
matchScore?: number | null;
|
||||
aiMetadata?: AiMetadata | null;
|
||||
}) => {
|
||||
const [updated] = await db
|
||||
.update(schema.application)
|
||||
.set({
|
||||
...(input.matchScore !== undefined ? { matchScore: input.matchScore } : {}),
|
||||
...(input.aiMetadata !== undefined ? { aiMetadata: input.aiMetadata } : {}),
|
||||
})
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
return stripUserId(updated);
|
||||
},
|
||||
|
||||
addNote: async (input: { id: string; userId: string; text: string }) => {
|
||||
// Append in a single statement (activity || [event]) so concurrent notes can't drop each
|
||||
// other via read-then-write; ownership is enforced by the WHERE clause.
|
||||
const event = activityEvent("note", input.text);
|
||||
const [updated] = await db
|
||||
.update(schema.application)
|
||||
.set({ activity: sql`${schema.application.activity} || ${JSON.stringify([event])}::jsonb` })
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
return stripUserId(updated);
|
||||
},
|
||||
|
||||
delete: async (input: { id: string; userId: string }) => {
|
||||
const result = await db
|
||||
.delete(schema.application)
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
|
||||
.returning({ id: schema.application.id });
|
||||
if (result.length === 0) throw new ORPCError("NOT_FOUND");
|
||||
},
|
||||
|
||||
bulkUpdate: async (input: {
|
||||
userId: string;
|
||||
ids: string[];
|
||||
status?: ApplicationStatus | undefined;
|
||||
archived?: boolean | undefined;
|
||||
addTags?: string[] | undefined;
|
||||
}) => {
|
||||
const scope = and(inArray(schema.application.id, input.ids), eq(schema.application.userId, input.userId));
|
||||
|
||||
// Tags: union the new tags into the existing array (de-duplicated) in a single statement.
|
||||
// Build an explicit `array[$1, $2]` — drizzle renders a bare JS array as a tuple `($1,$2)`,
|
||||
// which can't be cast to text[].
|
||||
const tagsExpr =
|
||||
input.addTags && input.addTags.length > 0
|
||||
? sql`(select array(select distinct unnest(${schema.application.tags} || array[${sql.join(
|
||||
input.addTags.map((tag) => sql`${tag}`),
|
||||
sql`, `,
|
||||
)}]::text[])))`
|
||||
: undefined;
|
||||
|
||||
// Stage moves must log a timeline event on every row that actually changed — mirror the
|
||||
// single-item update path. Append the event only where the current status differs.
|
||||
const activityExpr =
|
||||
input.status !== undefined
|
||||
? sql`case when ${schema.application.status} <> ${input.status}
|
||||
then ${schema.application.activity} || ${JSON.stringify([activityEvent("stage", `Moved to ${stageLabel(input.status)}`)])}::jsonb
|
||||
else ${schema.application.activity} end`
|
||||
: undefined;
|
||||
|
||||
const rows = await db
|
||||
.update(schema.application)
|
||||
.set({
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(activityExpr ? { activity: activityExpr } : {}),
|
||||
...(input.archived !== undefined ? { archived: input.archived } : {}),
|
||||
...(tagsExpr ? { tags: tagsExpr } : {}),
|
||||
})
|
||||
.where(scope)
|
||||
.returning({ id: schema.application.id });
|
||||
|
||||
return { updated: rows.length };
|
||||
},
|
||||
|
||||
bulkDelete: async (input: { userId: string; ids: string[] }) => {
|
||||
const rows = await db
|
||||
.delete(schema.application)
|
||||
.where(and(inArray(schema.application.id, input.ids), eq(schema.application.userId, input.userId)))
|
||||
.returning({ id: schema.application.id });
|
||||
return { deleted: rows.length };
|
||||
},
|
||||
|
||||
// Raw counts for Insights; funnel/sankey/tiles are derived client-side from these.
|
||||
stats: async (input: { userId: string; campaign?: string }) => {
|
||||
const scope = and(
|
||||
eq(schema.application.userId, input.userId),
|
||||
eq(schema.application.archived, false),
|
||||
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
|
||||
);
|
||||
|
||||
const byStage = await db
|
||||
.select({ status: schema.application.status, count: sql<number>`count(*)::int` })
|
||||
.from(schema.application)
|
||||
.where(scope)
|
||||
.groupBy(schema.application.status);
|
||||
|
||||
const bySource = await db
|
||||
.select({ source: schema.application.source, count: sql<number>`count(*)::int` })
|
||||
.from(schema.application)
|
||||
.where(scope)
|
||||
.groupBy(schema.application.source);
|
||||
|
||||
const total = byStage.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
byStage,
|
||||
bySource: bySource
|
||||
.filter((row): row is { source: string; count: number } => !!row.source)
|
||||
.sort((a, b) => b.count - a.count),
|
||||
};
|
||||
},
|
||||
|
||||
campaigns: async (input: { userId: string }) => {
|
||||
const rows = await db
|
||||
.select({ name: schema.application.campaign, count: sql<number>`count(*)::int` })
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false)))
|
||||
.groupBy(schema.application.campaign);
|
||||
|
||||
return rows.filter((row): row is { name: string; count: number } => !!row.name).sort((a, b) => b.count - a.count);
|
||||
},
|
||||
|
||||
listTags: async (input: { userId: string }) => {
|
||||
const rows = await db
|
||||
.select({ tag: sql<string>`distinct unnest(${schema.application.tags})` })
|
||||
.from(schema.application)
|
||||
.where(eq(schema.application.userId, input.userId));
|
||||
|
||||
return rows.map((row) => row.tag).sort((a, b) => a.localeCompare(b));
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { agentRouter } from "../features/agent/router";
|
||||
import { aiRouter } from "../features/ai/router";
|
||||
import { aiProvidersRouter } from "../features/ai-providers/router";
|
||||
import { applicationsRouter } from "../features/applications/router";
|
||||
import { authRouter } from "../features/auth/router";
|
||||
import { flagsRouter } from "../features/flags/router";
|
||||
import { resumeRouter } from "../features/resume/router";
|
||||
@@ -11,6 +12,7 @@ export default {
|
||||
ai: aiRouter,
|
||||
aiProviders: aiProvidersRouter,
|
||||
agent: agentRouter,
|
||||
applications: applicationsRouter,
|
||||
auth: authRouter,
|
||||
flags: flagsRouter,
|
||||
resume: resumeRouter,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"drizzle-kit": "1.0.0-rc.4",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
|
||||
import * as pg from "drizzle-orm/pg-core";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { user } from "./auth";
|
||||
import { resume } from "./resume";
|
||||
|
||||
// A tracked job application. Points at the live Reactive Resume that was sent (resumeId),
|
||||
// which is the reason this lives inside the product rather than a generic tracker.
|
||||
export const application = pg.pgTable(
|
||||
"application",
|
||||
{
|
||||
id: pg
|
||||
.text("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
userId: pg
|
||||
.text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
company: pg.text("company").notNull(),
|
||||
role: pg.text("role").notNull(),
|
||||
location: pg.text("location"),
|
||||
salary: pg.text("salary"),
|
||||
// Fixed pipeline stage enum (see applicationStatusSchema); stored as text per repo convention.
|
||||
status: pg.text("status").$type<ApplicationStatus>().notNull().default("saved"),
|
||||
archived: pg.boolean("archived").notNull().default(false),
|
||||
// Live link to one of the user's resumes. Kept on resume delete (set null) so the
|
||||
// application history survives.
|
||||
resumeId: pg.text("resume_id").references(() => resume.id, { onDelete: "set null" }),
|
||||
source: pg.text("source"),
|
||||
tags: pg.text("tags").array().notNull().default([]),
|
||||
// --- AI reservations (no working AI this pass; see feature AI roadmap) ---
|
||||
sourceUrl: pg.text("source_url"),
|
||||
jobDescription: pg.text("job_description"),
|
||||
matchScore: pg.integer("match_score"),
|
||||
aiMetadata: pg.jsonb("ai_metadata").$type<AiMetadata>(),
|
||||
// Reserves the deferred campaigns feature; no management UI this pass.
|
||||
campaign: pg.text("campaign"),
|
||||
notes: pg.text("notes"),
|
||||
followUpAt: pg.timestamp("follow_up_at", { withTimezone: true }),
|
||||
followUpNote: pg.text("follow_up_note"),
|
||||
contacts: pg.jsonb("contacts").notNull().$type<Contact[]>().default([]),
|
||||
activity: pg.jsonb("activity").notNull().$type<ActivityEvent[]>().default([]),
|
||||
appliedAt: pg.timestamp("applied_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
},
|
||||
(t) => [pg.index().on(t.userId), pg.index().on(t.userId, t.updatedAt.desc())],
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./agent";
|
||||
export * from "./applications";
|
||||
export * from "./auth";
|
||||
export * from "./resume";
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -21,7 +21,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^26.1.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./applications/*": "./src/applications/*.ts",
|
||||
"./icons": "./src/icons.ts",
|
||||
"./resume/*": "./src/resume/*.ts",
|
||||
"./schema.json": "./schema.json",
|
||||
@@ -21,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import z from "zod";
|
||||
|
||||
// Pipeline stages are a fixed enum for v1. If per-user custom stages are ever needed,
|
||||
// promote this to a table. `rejected` is a terminal stage; `archived` (a boolean on the
|
||||
// row) hides an application from the board without deleting it.
|
||||
export const applicationStatusSchema = z.enum(["saved", "applied", "screening", "interview", "offer", "rejected"]);
|
||||
|
||||
export type ApplicationStatus = z.infer<typeof applicationStatusSchema>;
|
||||
|
||||
// Ordered stage metadata shared by the API (validation) and the web board (columns/colors).
|
||||
export const STAGES = [
|
||||
{ value: "saved", label: "Saved", color: "oklch(0.62 0 0)" },
|
||||
{ value: "applied", label: "Applied", color: "oklch(0.52 0.19 285)" },
|
||||
{ value: "screening", label: "Screening", color: "oklch(0.45 0.08 195)" },
|
||||
{ value: "interview", label: "Interview", color: "oklch(0.5 0.1 70)" },
|
||||
{ value: "offer", label: "Offer", color: "oklch(0.55 0.15 152)" },
|
||||
{ value: "rejected", label: "Rejected", color: "oklch(0.63 0.12 22)" },
|
||||
] as const satisfies ReadonlyArray<{ value: ApplicationStatus; label: string; color: string }>;
|
||||
|
||||
export const contactSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
role: z.string().trim().default(""),
|
||||
// Free-form label shown as a pill: "Recruiter", "Referral", "Hiring Manager"…
|
||||
type: z.string().trim().default(""),
|
||||
});
|
||||
|
||||
export type Contact = z.infer<typeof contactSchema>;
|
||||
|
||||
// Timeline events: `stage` entries are auto-appended when an application moves; `note`
|
||||
// entries are added manually from the detail panel.
|
||||
export const activityEventSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["stage", "note", "created"]),
|
||||
text: z.string().trim().min(1),
|
||||
at: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type ActivityEvent = z.infer<typeof activityEventSchema>;
|
||||
|
||||
// Reserved for AI enrichment output (autofill / match-score). Free-form so the shape can
|
||||
// evolve without a migration. See the AI roadmap in the applications feature.
|
||||
export const aiMetadataSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
export type AiMetadata = z.infer<typeof aiMetadataSchema>;
|
||||
@@ -40,7 +40,7 @@
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
||||
import { CheckIcon, MinusIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive data-disabled:cursor-not-allowed data-checked:border-primary data-indeterminate:border-primary data-checked:bg-primary data-indeterminate:bg-primary data-checked:text-primary-foreground data-indeterminate:text-primary-foreground data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
{props.indeterminate ? (
|
||||
<MinusIcon weight="bold" className="size-3" />
|
||||
) : (
|
||||
<CheckIcon weight="bold" className="size-3" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -33,7 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^26.1.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+724
-127
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"pg": "^8.22.0",
|
||||
"tsx": "^4.23.0"
|
||||
|
||||
Reference in New Issue
Block a user