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 14df785d5..05bc56186 100644 --- a/apps/web/src/features/applications/components/application-detail-sheet.tsx +++ b/apps/web/src/features/applications/components/application-detail-sheet.tsx @@ -1,4 +1,4 @@ -import type { ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data"; +import type { ApplicationStatus, ApplicationTimelineEntry, Contact } from "@reactive-resume/schema/applications/data"; import type { Application } from "../types"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -17,8 +17,17 @@ 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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@reactive-resume/ui/components/dialog"; import { Input } from "@reactive-resume/ui/components/input"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet"; +import { Textarea } from "@reactive-resume/ui/components/textarea"; import { cn } from "@reactive-resume/utils/style"; import { useConfirm } from "@/hooks/use-confirm"; import { orpc } from "@/libs/orpc/client"; @@ -27,6 +36,24 @@ import { ApplicationAiCopilot } from "./application-ai-copilot"; import { FileAttachmentField } from "./file-attachment-field"; const stageIndex = (status: ApplicationStatus) => STAGES.findIndex((s) => s.value === status); +const stageOf = (status: ApplicationStatus) => STAGES.find((s) => s.value === status); + +const dateInputValue = (value: Date | string) => { + const date = new Date(value); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; +}; + +const formatDate = (value: Date | string) => + new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC", year: "numeric" }); + +const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) => + new Date(b.at).getTime() - new Date(a.at).getTime(); + +const currentStageAnchorId = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) => + [...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.id; + +const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) => + [...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at; type Props = { application: Application | null; @@ -37,17 +64,8 @@ type Props = { export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Props) { const queryClient = useQueryClient(); const confirm = useConfirm(); - 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, @@ -71,14 +89,25 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr const addNote = useMutation( orpc.applications.addNote.mutationOptions({ - onSuccess: () => { - setNote(""); - invalidate(); - }, + onSuccess: invalidate, onError: () => toast.error(t`Couldn't save the note.`), }), ); + const updateTimelineEntry = useMutation( + orpc.applications.updateTimelineEntry.mutationOptions({ + onSuccess: invalidate, + onError: () => toast.error(t`Couldn't update the timeline entry.`), + }), + ); + + const deleteTimelineEntry = useMutation( + orpc.applications.deleteTimelineEntry.mutationOptions({ + onSuccess: invalidate, + onError: () => toast.error(t`Couldn't delete the timeline entry.`), + }), + ); + const remove = useMutation( orpc.applications.delete.mutationOptions({ onSuccess: () => { @@ -145,7 +174,10 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
- +
{current.sourceUrl && ( @@ -240,40 +272,21 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr )} - {/* activity timeline */} -
-
- {[...current.activity] - .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()) - .map((event) => ( -
- -
-
{event.text}
-
{new Date(event.at).toLocaleString()}
-
-
- ))} -
-
- setNote(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && note.trim()) addNote.mutate({ id: current.id, text: note.trim() }); - }} - /> - -
-
+ addNote.mutateAsync({ id: current.id, text })} + onUpdateEntry={(entryId, input) => updateTimelineEntry.mutateAsync({ id: current.id, entryId, ...input })} + onDeleteEntry={(entryId) => { + void confirm(t`Delete this timeline entry?`, { + description: t`This entry will be permanently deleted. This can't be undone.`, + confirmText: t`Delete`, + }).then((confirmed) => { + if (confirmed) deleteTimelineEntry.mutate({ id: current.id, entryId }); + }); + }} + />
@@ -317,6 +330,206 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr ); } +type ApplicationTimelineProps = { + application: Application; + pending: boolean; + onAddNote: (text: string) => Promise; + onUpdateEntry: (entryId: string, input: { date?: string; text?: string }) => Promise; + onDeleteEntry: (entryId: string) => void; +}; + +function ApplicationTimeline({ + application, + pending, + onAddNote, + onUpdateEntry, + onDeleteEntry, +}: ApplicationTimelineProps) { + const [note, setNote] = useState(""); + const [editingDate, setEditingDate] = useState(null); + const [editingNote, setEditingNote] = useState<(ApplicationTimelineEntry & { type: "note" }) | null>(null); + const [dateDraft, setDateDraft] = useState(""); + const [noteDraft, setNoteDraft] = useState(""); + const anchorId = currentStageAnchorId(application.activity, application.status); + const sorted = [...application.activity].sort(byNewest); + + const openDate = (entry: ApplicationTimelineEntry) => { + setEditingDate(entry); + setDateDraft(dateInputValue(entry.at)); + }; + + const openNote = (entry: ApplicationTimelineEntry & { type: "note" }) => { + setEditingNote(entry); + setNoteDraft(entry.text); + }; + + const add = () => { + if (pending) return; + const text = note.trim(); + if (!text) return; + void onAddNote(text) + .then(() => setNote("")) + .catch(() => false); + }; + + return ( +
+
+
+ setNote(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + add(); + } + }} + /> + +
+ +
+ {sorted.map((entry) => { + const stage = entry.type === "stage" ? stageOf(entry.stage) : null; + const isAnchor = entry.id === anchorId; + return ( +
+ +
+
+ {entry.type === "stage" ? ( +
+ Moved to {stage?.label ?? entry.stage} +
+ ) : ( + + )} + {isAnchor && ( +
+ Current stage +
+ )} +
+
+ + {!isAnchor && ( + + )} +
+
+
+ ); + })} +
+
+ + !open && setEditingDate(null)}> + + + + Edit date + + + Update the calendar date for this timeline entry. + + + setDateDraft(event.currentTarget.value)} + onChange={(event) => setDateDraft(event.target.value)} + /> + + + + + + + + !open && setEditingNote(null)}> + + + + Edit note + + + Update this timeline note. + + +