diff --git a/apps/web/src/features/applications/components/add-application-dialog.tsx b/apps/web/src/features/applications/components/add-application-dialog.tsx
new file mode 100644
index 000000000..800162195
--- /dev/null
+++ b/apps/web/src/features/applications/components/add-application-dialog.tsx
@@ -0,0 +1,298 @@
+import type { RouterOutput } from "@/libs/orpc/client";
+import { t } from "@lingui/core/macro";
+import { Plural, Trans } from "@lingui/react/macro";
+import { useMutation } from "@tanstack/react-query";
+import { useEffect, useId, useState } from "react";
+import { Button } from "@reactive-resume/ui/components/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@reactive-resume/ui/components/dialog";
+import { Icon } from "@reactive-resume/ui/components/icon";
+import { Input } from "@reactive-resume/ui/components/input";
+import { Label } from "@reactive-resume/ui/components/label";
+import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
+import { Textarea } from "@reactive-resume/ui/components/textarea";
+import { toast } from "@reactive-resume/ui/components/toast";
+import { cn } from "@reactive-resume/utils/style";
+import { useDialogStore } from "@/dialogs/store";
+import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
+import { getOrpcErrorMessage } from "@/libs/error-message";
+import { orpc } from "@/libs/orpc/client";
+import { getStageLabel } from "../stages";
+import { useInvalidateApplications } from "../use-application-actions";
+
+type Parsed = RouterOutput["applications"]["ai"]["parsePosting"];
+type Stage = "saved" | "applied" | "interview";
+
+const STAGES: readonly Stage[] = ["saved", "applied", "interview"];
+const MAX_POSTING_CHARS = 20_000;
+// Reading waits for a pause in typing, so it starts once the paste has landed.
+const READ_DELAY_MS = 600;
+
+const isLink = (value: string) => /^https?:\/\/\S+$/i.test(value.trim());
+
+type AddApplicationDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onAdded: (id: string) => void;
+};
+
+/**
+ * Add an application from a pasted link or posting. The posting is read (the page's own job data, or the AI
+ * provider when one is set up) into role and company, which stay editable; the posting itself is saved with the
+ * application for Check, the assistant and letters. Add, or Add and tailor a resume.
+ */
+export function AddApplicationDialog({ open, onOpenChange, onAdded }: AddApplicationDialogProps) {
+ // A fresh form for every open, kept in place while the dialog animates closed.
+ const [instance, setInstance] = useState(0);
+ const [wasOpen, setWasOpen] = useState(open);
+ if (open !== wasOpen) {
+ setWasOpen(open);
+ if (open) setInstance((count) => count + 1);
+ }
+
+ return (
+
+ );
+}
+
+function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded: (id: string) => void }) {
+ const id = useId();
+ const [input, setInput] = useState("");
+ const [role, setRole] = useState("");
+ const [company, setCompany] = useState("");
+ const [stage, setStage] = useState("applied");
+ const [parsed, setParsed] = useState(null);
+ const { hasUsableProvider } = useHasUsableAiProvider();
+ const invalidate = useInvalidateApplications();
+ const openDialog = useDialogStore((state) => state.openDialog);
+
+ const read = useMutation(orpc.applications.ai.parsePosting.mutationOptions());
+ const { mutate: readPosting, reset: resetRead } = read;
+ const create = useMutation(orpc.applications.create.mutationOptions());
+
+ const text = input.trim();
+ const link = isLink(text);
+ // A link can always be read (for the page's own job data); pasted text needs the AI provider.
+ const readable = text.length > 8 && (link || hasUsableProvider);
+
+ useEffect(() => {
+ setParsed(null);
+ resetRead();
+ if (!readable) return;
+
+ const timeout = window.setTimeout(() => {
+ readPosting(
+ { input: text },
+ {
+ onSuccess: (result) => {
+ setParsed(result);
+ setRole((current) => current || result.role);
+ setCompany((current) => current || result.company);
+ },
+ },
+ );
+ }, READ_DELAY_MS);
+ return () => window.clearTimeout(timeout);
+ }, [text, readable, readPosting, resetRead]);
+
+ const ready = role.trim().length > 0 && company.trim().length > 0 && !read.isPending && !create.isPending;
+
+ const add = async (tailor: boolean) => {
+ if (!ready) return;
+ try {
+ const applicationId = await create.mutateAsync({
+ company: company.trim(),
+ role: role.trim(),
+ status: stage,
+ ...(parsed?.location ? { location: parsed.location } : {}),
+ ...(parsed?.salary ? { salary: parsed.salary } : {}),
+ ...(parsed?.requirements.length ? { requirements: parsed.requirements } : {}),
+ ...(link ? { sourceUrl: text } : {}),
+ // The posting's text: read from the page for a link, or what was pasted.
+ ...(parsed?.jobDescription
+ ? { jobDescription: parsed.jobDescription }
+ : !link && text
+ ? { jobDescription: text.slice(0, MAX_POSTING_CHARS) }
+ : {}),
+ });
+ invalidate();
+ toast.add({ description: t`Added ${role.trim()} at ${company.trim()}` });
+ onClose();
+ onAdded(applicationId);
+ if (tailor) openDialog("document.new", { step: "copy", applicationId });
+ } catch (error) {
+ toast.add({
+ type: "error",
+ description: getOrpcErrorMessage(error, { fallback: t`Couldn't add the application.` }),
+ });
+ }
+ };
+
+ return (
+
+ );
+}
+
+type ReadStatusProps = {
+ text: string;
+ link: boolean;
+ readable: boolean;
+ pending: boolean;
+ error: unknown;
+ parsed: Parsed | null;
+ ai: boolean;
+};
+
+/** What reading the posting found, or why the fields need filling in by hand. */
+function ReadStatus({ text, link, readable, pending, error, parsed, ai }: ReadStatusProps) {
+ const base = "flex items-start gap-1.5 text-xs leading-[17px]";
+
+ if (!text) {
+ return (
+
+ We read the page and save the posting for Check, the assistant and your letter.
+
+ );
+ }
+ if (pending) {
+ return (
+
+ {link ? Reading the link… : Reading the posting…}
+
+ );
+ }
+ if (error) {
+ return (
+
+
+ {getOrpcErrorMessage(error, {
+ byCode: { POSTING_UNREADABLE: t`That link couldn't be read. Paste the posting text instead.` },
+ fallback: t`The posting couldn't be read. Fill in the role and company.`,
+ })}
+
+
+ Found the role and company on the page. The posting is saved with the application.
+
+ );
+ }
+
+ return (
+
+ {link && readable ? (
+ The page had no job details to read. Fill in the role and company.
+ ) : ai ? (
+ Fill in the role and company. The posting is saved with the application.
+ ) : (
+ Fill in the role and company. Connect an AI provider in settings to read them from the posting.
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/applications/components/application-actions-menu.tsx b/apps/web/src/features/applications/components/application-actions-menu.tsx
index f577a69c3..1077421b4 100644
--- a/apps/web/src/features/applications/components/application-actions-menu.tsx
+++ b/apps/web/src/features/applications/components/application-actions-menu.tsx
@@ -1,16 +1,6 @@
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 { STAGES } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
@@ -22,56 +12,26 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
-import { toast } from "@reactive-resume/ui/components/toast";
+import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
-import { orpc } from "@/libs/orpc/client";
-import { applicationsListQueryKey } from "../queries";
+import { CLOSED_REASONS, getClosedReasonLabel, getStageColor, getStageLabel, PIPELINE } from "../stages";
+import { useApplicationActions } from "../use-application-actions";
-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;
-};
-
-// Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
-// React portals bubble synthetic events through the React tree, so a menu-item click would
-// otherwise reach the card's onClick even though the menu is portaled in the DOM.
+// Keep pointer and clicks from reaching the card, which would start a drag or open the detail sheet. React portals
+// bubble synthetic events through the React tree, so a menu item's click would otherwise reach the card.
const stop = (event: React.SyntheticEvent) => event.stopPropagation();
-// 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();
+type ApplicationActionsMenuProps = { application: Application; className?: string };
+
+/** A card's ⋯ menu, the keyboard alternative to dragging: Move to…, Close…, and Delete after asking. */
+export function ApplicationActionsMenu({ application, className }: ApplicationActionsMenuProps) {
+ const { moveTo, close, remove } = useApplicationActions();
const confirm = useConfirm();
- 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.add({ type: "error", description: t`Something went wrong. Please try again.` }),
- }),
- );
-
- const remove = useMutation(
- orpc.applications.delete.mutationOptions({
- onSuccess: () => {
- invalidate();
- toast.add({ type: "success", description: t`Application deleted.` });
- },
- onError: () => toast.add({ type: "error", description: t`Couldn't delete the application.` }),
- }),
- );
-
const onDelete = async () => {
const confirmed = await confirm(t`Delete this application?`, {
- description: t`"${application.role} · ${application.company}" and its full timeline will be permanently deleted. This can't be undone.`,
+ description: t`“${application.role} · ${application.company}” and its timeline are deleted permanently. This can't be undone.`,
confirmText: t`Delete`,
});
if (confirmed) remove.mutate({ id: application.id });
@@ -83,56 +43,60 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
-
-
+ className="text-ink-3"
+ />
}
- />
-
- onEdit(application)}>
-
- Edit
-
-
+ >
+
+
+
-
- Move to
+
+ Move to…
- {STAGES.map((stage) => (
+ {PIPELINE.map((status) => (
update.mutate({ id: application.id, status: stage.value })}
+ key={status}
+ disabled={status === application.status}
+ onClick={() => moveTo(application, status)}
>
-
- {stage.label}
+
+ {getStageLabel(status)}
))}
- update.mutate({ id: application.id, archived: !application.archived })}>
- {application.archived ? : }
- {application.archived ? Unarchive : Archive}
-
+
+
+
+ Close…
+
+
+ {CLOSED_REASONS.map((reason) => (
+ close(application, reason)}>
+ {getClosedReasonLabel(reason)}
+
+ ))}
+
+
-
- Delete
+
+ Delete…
diff --git a/apps/web/src/features/applications/components/application-ai-copilot.test.tsx b/apps/web/src/features/applications/components/application-ai-copilot.test.tsx
index 46c77e1eb..509225496 100644
--- a/apps/web/src/features/applications/components/application-ai-copilot.test.tsx
+++ b/apps/web/src/features/applications/components/application-ai-copilot.test.tsx
@@ -35,6 +35,11 @@ const application: Application = {
salary: null,
status: "saved",
archived: false,
+ closedReason: null,
+ coverLetterId: null,
+ sentResumeVersionId: null,
+ sentCheckScore: null,
+ requirements: [],
resumeId: null,
source: null,
sourceUrl: null,
diff --git a/apps/web/src/features/applications/components/application-card.tsx b/apps/web/src/features/applications/components/application-card.tsx
index 1ff57d43f..faf23b958 100644
--- a/apps/web/src/features/applications/components/application-card.tsx
+++ b/apps/web/src/features/applications/components/application-card.tsx
@@ -1,95 +1,56 @@
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 { useLingui } from "@lingui/react";
+import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
+import { describeNextStep, getNextStep } from "../next-step";
import { ApplicationActionsMenu } from "./application-actions-menu";
-type Props = {
+type ApplicationCardProps = {
application: Application;
onClick?: () => void;
- onEdit?: (application: Application) => void;
- className?: string;
+ /** The ⋯ menu (Move to…, Close…, Delete); cards dragged in the overlay go without. */
+ withMenu?: boolean;
dragging?: boolean;
};
-export function ApplicationCard({ application, onClick, onEdit, className, dragging }: Props) {
- const followUp = application.followUpAt && !application.archived;
+/** A board card: the company's initial, role and company, and the next step (warn when it's overdue). */
+export function ApplicationCard({ application, onClick, withMenu = false, dragging = false }: ApplicationCardProps) {
+ const { i18n } = useLingui();
+ const next = describeNextStep(getNextStep(application), application, i18n.locale);
return (
- // biome-ignore lint/a11y/useSemanticElements: the card nests an actions-menu