From 462db6011aa94ab414e7228f76dd3f36444f928c Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 22:14:20 +0200 Subject: [PATCH] feat(web): applications as a grouped list, a rebuilt detail sheet and an add dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The page: CSV import/export behind one icon, Add application, a dismissible nudge for the application waiting longest without a reply (10+ days), List · Board · Insights · Calendar, search across role, company, contacts and tags, and Show closed. - List (the default): grouped by stage in the order that needs you first, with collapsible groups, the next step (warn when overdue), what was sent, sorting from the headers and row checkboxes for bulk moves, tags, closing and deleting. Phones get two-line rows and no board. - Board: a column per stage; drops and Move to… show the same toast. - Detail sheet (480 px, full screen on phones): the stage stepper with Move to next, the next step (the next interview or follow-up) with Edit and Add to calendar (.ics), what was sent (opening the version sent, read-only, in History), Tailor a resume and Write a letter, editable salary and source, contacts, tags, autosaved notes and the activity timeline. Close application… takes a reason; Delete is in ⋯ and asks first. - Add dialog: paste a link or posting; its role, company and requirements are read into editable fields and the posting is saved with the application. Add, or Add and tailor a resume. - Insights: how far applications get (from their stage history), how many heard back and how fast, and tailored against base resumes, above the existing charts. - CSV import shows how columns were matched before saving and lets you download the rows it skips; export adds the closed reason. - The builder opens History on a version from ?version=. --- .../components/add-application-dialog.tsx | 298 ++++ .../components/application-actions-menu.tsx | 128 +- .../application-ai-copilot.test.tsx | 5 + .../components/application-card.tsx | 109 +- .../components/application-detail-sheet.tsx | 1220 ++++++----------- .../applications/components/board.tsx | 152 +- .../components/detail/activity.tsx | 249 ++++ .../components/detail/close-dialog.tsx | 70 + .../components/detail/contacts.tsx | 150 ++ .../components/detail/next-step-card.tsx | 202 +++ .../components/detail/sent-documents.tsx | 201 +++ .../components/export-applications-sheet.tsx | 2 +- .../components/import-applications-sheet.tsx | 255 ++-- .../applications/components/insights-view.tsx | 142 +- .../components/interview-dialog.tsx | 2 +- .../applications/components/list-view.tsx | 409 ++++++ .../applications/components/table-view.tsx | 397 ------ .../features/applications/csv-export.test.ts | 5 + .../web/src/features/applications/csv.test.ts | 23 +- apps/web/src/features/applications/csv.ts | 63 +- .../web/src/features/applications/ics.test.ts | 43 + apps/web/src/features/applications/ics.ts | 52 + .../features/applications/insights.test.ts | 67 +- .../web/src/features/applications/insights.ts | 100 +- .../features/applications/next-step.test.ts | 63 + .../src/features/applications/next-step.ts | 130 ++ apps/web/src/features/applications/stages.ts | 50 + .../applications/use-application-actions.ts | 89 ++ .../features/documents/document-actions.tsx | 2 +- .../documents/new-document-dialog.tsx | 10 +- .../resume/editor/check/job-match.tsx | 2 +- .../resume/editor/use-editor-mode.test.ts | 4 +- .../features/resume/editor/use-editor-mode.ts | 10 +- .../src/features/resume/share/format.test.ts | 2 + apps/web/src/features/resume/share/format.ts | 3 +- .../$resumeId/-components/editor-shell.tsx | 23 +- .../src/routes/builder/$resumeId/route.tsx | 2 + .../dashboard/-components/app-shell.tsx | 2 +- .../dashboard/applications/-index.test.tsx | 62 +- .../routes/dashboard/applications/index.tsx | 567 ++++---- .../src/icons/material-symbols-rounded.json | 5 + .../src/icons/material-symbols-rounded.woff2 | Bin 38256 -> 39312 bytes packages/ui/src/icons/names.ts | 5 + 43 files changed, 3458 insertions(+), 1917 deletions(-) create mode 100644 apps/web/src/features/applications/components/add-application-dialog.tsx create mode 100644 apps/web/src/features/applications/components/detail/activity.tsx create mode 100644 apps/web/src/features/applications/components/detail/close-dialog.tsx create mode 100644 apps/web/src/features/applications/components/detail/contacts.tsx create mode 100644 apps/web/src/features/applications/components/detail/next-step-card.tsx create mode 100644 apps/web/src/features/applications/components/detail/sent-documents.tsx create mode 100644 apps/web/src/features/applications/components/list-view.tsx delete mode 100644 apps/web/src/features/applications/components/table-view.tsx create mode 100644 apps/web/src/features/applications/ics.test.ts create mode 100644 apps/web/src/features/applications/ics.ts create mode 100644 apps/web/src/features/applications/next-step.test.ts create mode 100644 apps/web/src/features/applications/next-step.ts create mode 100644 apps/web/src/features/applications/stages.ts create mode 100644 apps/web/src/features/applications/use-application-actions.ts 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 ( + + + onOpenChange(false)} onAdded={onAdded} /> + + + ); +} + +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 ( +
{ + event.preventDefault(); + void add(true); + }} + > + + + Add an application + + + Paste a job link or posting, then check the role and company. + + + +
+ +