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.
+
+
- {/* Pasted job description: stored with the application and used for every AI action.
- Collapsed by default so the form stays short; hidden entirely when AI is off. */}
+ {/* Hidden entirely when AI is off. */}
{aiEnabled && (
-
-
-
-
-
- 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.
-
-