diff --git a/apps/web/src/features/applications/components/add-application-dialog.tsx b/apps/web/src/features/applications/components/add-application-dialog.tsx index 800162195..28c057ae4 100644 --- a/apps/web/src/features/applications/components/add-application-dialog.tsx +++ b/apps/web/src/features/applications/components/add-application-dialog.tsx @@ -109,22 +109,23 @@ function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded const add = async (tailor: boolean) => { if (!ready) return; + const posting = { + 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) } + : {}), + }; 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) } - : {}), - }); + const applicationId = await create.mutateAsync(posting); invalidate(); toast.add({ description: t`Added ${role.trim()} at ${company.trim()}` }); onClose(); diff --git a/apps/web/src/features/applications/components/application-detail-sheet.tsx b/apps/web/src/features/applications/components/application-detail-sheet.tsx index d9e65f471..03b8cf465 100644 --- a/apps/web/src/features/applications/components/application-detail-sheet.tsx +++ b/apps/web/src/features/applications/components/application-detail-sheet.tsx @@ -30,6 +30,7 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; import { cn } from "@reactive-resume/utils/style"; import { useClosingValue } from "@/hooks/use-closing-value"; import { useConfirm } from "@/hooks/use-confirm"; +import { isImeComposing } from "@/libs/keyboard"; import { orpc } from "@/libs/orpc/client"; import { stageSince } from "../next-step"; import { getClosedReasonLabel, getNextStage, getStageColor, getStageLabel, PIPELINE } from "../stages"; @@ -231,7 +232,7 @@ function Detail({ application, onEditDetails, onDeleted }: DetailProps) { -
+
setInterview({ open: true, entry })} /> @@ -353,6 +354,7 @@ function InlineFact({ label, value, onSave }: InlineFactProps) { onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { + if (isImeComposing(event)) return; if (event.key === "Enter") commit(); if (event.key === "Escape") { event.stopPropagation(); @@ -442,7 +444,6 @@ function Notes({ application }: { application: Application }) { // Closing the sheet mid-sentence still saves what was typed. const latest = useRef(notes); - latest.current = notes; useEffect( () => () => { if (latest.current !== saved.current) @@ -461,7 +462,10 @@ function Notes({ application }: { application: Application }) { rows={3} value={notes} placeholder={t`Anything to remember about this job`} - onChange={(event) => setNotes(event.target.value)} + onChange={(event) => { + latest.current = event.target.value; + setNotes(event.target.value); + }} /> ); diff --git a/apps/web/src/features/applications/components/application-form-sheet.tsx b/apps/web/src/features/applications/components/application-form-sheet.tsx index 1f8f6461d..a54aaebee 100644 --- a/apps/web/src/features/applications/components/application-form-sheet.tsx +++ b/apps/web/src/features/applications/components/application-form-sheet.tsx @@ -4,7 +4,7 @@ import type { FileAttachment } from "./file-attachment-field"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useId, useState } from "react"; import { STAGES } from "@reactive-resume/schema/applications/data"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion"; import { Button } from "@reactive-resume/ui/components/button"; @@ -23,6 +23,7 @@ import { Textarea } from "@reactive-resume/ui/components/textarea"; import { toast } from "@reactive-resume/ui/components/toast"; import { Combobox } from "@/components/ui/combobox"; import { useClosingValue } from "@/hooks/use-closing-value"; +import { isImeComposing } from "@/libs/keyboard"; import { orpc } from "@/libs/orpc/client"; import { applicationsListQueryKey } from "../queries"; import { FileAttachmentField } from "./file-attachment-field"; @@ -80,6 +81,112 @@ function toForm(app: Application): FormState { }; } +function toPayload(form: FormState) { + return { + company: form.company.trim(), + role: form.role.trim(), + status: form.status, + location: form.location.trim() || null, + salary: form.salary.trim() || null, + source: form.source.trim() || null, + resumeId: form.resumeId || null, + tags: form.tags, + 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, + resumeFileUrl: form.resumeFile?.url ?? null, + resumeFileName: form.resumeFile?.name ?? null, + coverLetterUrl: form.coverLetter?.url ?? null, + coverLetterName: form.coverLetter?.name ?? null, + }; +} + +type AutofillResult = { + company?: string | null; + role?: string | null; + location?: string | null; + salary?: string | null; +}; + +type JobDescriptionAutofillProps = { + value: string; + onChange: (value: string) => void; + onFill: (result: AutofillResult) => void; +}; + +// Pasted job description: stored with the application and used for every AI action. +// Collapsed by default so the form stays short. +function JobDescriptionAutofill({ value, onChange, onFill }: JobDescriptionAutofillProps) { + const autofill = useMutation( + orpc.applications.ai.autofill.mutationOptions({ + onSuccess: (result) => { + onFill(result); + toast.add({ type: "success", description: t`Filled in what we could from the posting.` }); + }, + onError: (error) => toast.add({ type: "error", description: error.message || t`Auto-fill failed.` }), + }), + ); + + const runAutofill = (jobDescription: string) => { + const posting = jobDescription.trim(); + if (posting.length < MIN_AUTOFILL_CHARS || autofill.isPending) return; + autofill.mutate({ jobDescription: posting.slice(0, MAX_JOB_DESCRIPTION_CHARS) }); + }; + + return ( + + + + + + Job description + + + +

+ + Copy the entire job description from the posting and paste it below. We'll fill in the fields for you and + keep the text with this application for match scoring and tailoring. + +

+