"use client"; import { useState, useTransition } from "react"; import { HandshakeIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import type { Stage } from "@prisma/client"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { DateField } from "@/components/ui/date-field"; import { SaveIndicator } from "@/components/save-indicator"; import { useAutosave } from "@/hooks/use-autosave"; import { useViewerZone } from "@/components/viewer-zone"; import { civilDay, shortCivilDay } from "@/lib/time"; import { cn } from "@/lib/utils"; import { parseAmount } from "@/lib/money"; import { deleteOfferAction, recordOfferAction, updateOfferAction } from "@/server/actions"; /** * What they actually offered, on the application it belongs to. * * Two things on this screen look like the same fact and are not. The Salary * field in the rail is what the POSTING advertised — a range somebody else * wrote, often a lie. This card is what a named human put on the table. They * are never merged, and the advertised range is printed here beside the real * number precisely so the gap is visible while you are deciding what to say. * * Editing the numbers above autosaves, like everything else in this app: that * is for fixing a base you typed with a digit missing. A better number is not * an edit — "Record a revision" writes a second version and leaves the first * one standing, because the distance between the opening offer and the closing * one is the only record anybody has that negotiating worked. */ export type OfferValue = { id: string; currency: string; baseAmount: number; bonusAmount: number; equityAmount: number; signOnAmount: number; terms: string; vesting: string; receivedAt: string; respondBy: string | null; startsOn: string | null; }; type Draft = { currency: string; baseAmount: string; bonusAmount: string; equityAmount: string; signOnAmount: string; terms: string; vesting: string; respondBy: string; startsOn: string; }; const BLANK: Draft = { currency: "USD", baseAmount: "", bonusAmount: "", equityAmount: "", signOnAmount: "", terms: "", vesting: "", respondBy: "", startsOn: "", }; function draftOf(offer: OfferValue): Draft { const amount = (value: number) => (value === 0 ? "" : String(value)); return { currency: offer.currency, baseAmount: amount(offer.baseAmount), bonusAmount: amount(offer.bonusAmount), equityAmount: amount(offer.equityAmount), signOnAmount: amount(offer.signOnAmount), terms: offer.terms, vesting: offer.vesting, respondBy: offer.respondBy ?? "", startsOn: offer.startsOn ?? "", }; } /** Whole days to a deadline, negative once it has passed. Null when there is none. */ function daysUntil(value: string): number | null { if (!value) return null; const due = new Date(value); if (Number.isNaN(due.getTime())) return null; return Math.ceil((due.getTime() - Date.now()) / 86_400_000); } /** * The four amounts, read by the data layer's own parser (src/lib/money.ts). An * emptied field is an explicit 0 — the columns hold a number, and "not sent" * would leave the old figure standing. A figure the parser refuses ("€215.000", * a bare "$") throws with its sentence, which the card shows instead of saving. */ function amountsOf(draft: Draft) { const read = (text: string, field: string) => (text.trim() === "" ? 0 : (parseAmount(text, field) ?? 0)); return { baseAmount: read(draft.baseAmount, "Base"), bonusAmount: read(draft.bonusAmount, "Bonus"), equityAmount: read(draft.equityAmount, "Equity"), signOnAmount: read(draft.signOnAmount, "Sign-on"), }; } /** For display only: a figure still being typed counts as nothing yet. */ function totalOf(draft: Draft) { try { const amounts = amountsOf(draft); return amounts.baseAmount + amounts.bonusAmount + amounts.equityAmount; } catch { return 0; } } /** * A figure in its own currency, whole units, no decimals. * * Falls back to the code and the digits when a browser does not know the * currency: an unfamiliar code should print "PLN 320,000", never throw a card * off the page. */ export function formatMoney(amount: number, currency: string): string { try { return new Intl.NumberFormat(undefined, { style: "currency", currency, maximumFractionDigits: 0, }).format(amount); } catch { return `${currency} ${amount.toLocaleString()}`; } } export function OfferCard({ applicationId, stage, advertised, offers, onServerChange, }: { applicationId: string; stage: Stage; /** What the posting claimed, for the line beside the real number. */ advertised: string; /** Every version, newest first. */ offers: OfferValue[]; onServerChange?: () => void; }) { const zone = useViewerZone(); const current = offers[0] ?? null; const [draft, setDraft] = useState(current ? draftOf(current) : BLANK); // Which row the draft belongs to. A revision or a delete changes the row // under us, and without this the fields would keep editing the old id. const [editing, setEditing] = useState(current?.id ?? null); const [pending, start] = useTransition(); if (editing !== (current?.id ?? null)) { setEditing(current?.id ?? null); setDraft(current ? draftOf(current) : BLANK); } const [amountError, setAmountError] = useState(null); const save = useAutosave(async (value) => { if (!current) return; let amounts: ReturnType; try { amounts = amountsOf(value); setAmountError(null); } catch (error) { // Not saved, and said so beside the figures, rather than stored as a // number a thousand times off. setAmountError(error instanceof Error ? error.message : "That figure is not a number."); return; } await updateOfferAction(current.id, { currency: value.currency, ...amounts, terms: value.terms, vesting: value.vesting, respondBy: value.respondBy, startsOn: value.startsOn, }); }); const set = (patch: Partial) => { const next = { ...draft, ...patch }; setDraft(next); if (current) save.push(next); }; const record = () => start(async () => { try { await recordOfferAction(applicationId, { currency: draft.currency, ...amountsOf(draft), terms: draft.terms, vesting: draft.vesting, respondBy: draft.respondBy, startsOn: draft.startsOn, }); toast.success(current ? "Revision recorded. The first offer is still on file." : "Offer recorded."); onServerChange?.(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not save that."); } }); const remove = (id: string) => start(async () => { try { await deleteOfferAction(id); onServerChange?.(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not delete that."); } }); // The card appears when there is something to put in it. Before the offer // stage there is nothing to record, and a form for a number nobody has said // is an invitation to write down a number nobody has said. if (stage !== "OFFER" && stage !== "ACCEPTED" && offers.length === 0) return null; const total = totalOf(draft); let signOn = 0; try { signOn = parseAmount(draft.signOnAmount, "Sign-on") ?? 0; } catch { // Still being typed; the save path says what is wrong with it. } const days = daysUntil(draft.respondBy); return ( Offer {offers.length > 1 && ( version {offers.length} )}
{amountError && {amountError}}
{total > 0 ? formatMoney(total, draft.currency) : "—"} a year{signOn > 0 && `, plus ${formatMoney(signOn, draft.currency)} on signing`} {advertised.trim() && ( · the posting said {advertised.trim()} )}
set({ baseAmount })} /> set({ bonusAmount })} /> set({ equityAmount })} /> set({ signOnAmount })} />
set({ currency: event.target.value.toUpperCase().slice(0, 3) })} className="nums uppercase" aria-label="Currency" />
set({ respondBy })} ariaLabel="Answer by" placeholder="No deadline" tone={days !== null && days < 0 ? "overdue" : undefined} /> {days !== null && (

{days < 0 ? `${Math.abs(days)} days ago` : days === 0 ? "Today" : `${days} days left`}

)}
set({ startsOn })} ariaLabel="Start date" placeholder="Not agreed" />
set({ vesting: event.target.value })} placeholder="4 years, 1 year cliff, monthly after" />