Ctrl K
ComunidadGuías…

src/components/pipeline/offer-card.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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<Draft>(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<string | null>(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<string | null>(null);
  const save = useAutosave<Draft>(async (value) => {
    if (!current) return;
    let amounts: ReturnType<typeof amountsOf>;
    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<Draft>) => {
    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 (
    <Card>
      <CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
        <CardTitle className="flex items-center gap-2 text-[15px]">
          <HandshakeIcon className="text-muted-foreground size-4" />
          Offer
          {offers.length > 1 && (
            <span className="text-faint nums text-[11px] font-normal">
              version {offers.length}
            </span>
          )}
        </CardTitle>
        <div className="flex items-center gap-2">
          {amountError && <span className="text-destructive text-[11px]">{amountError}</span>}
          <SaveIndicator state={save.state} />
        </div>
      </CardHeader>

      <CardContent className="space-y-5">
        <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
          <span className="nums text-2xl font-semibold tracking-tight">
            {total > 0 ? formatMoney(total, draft.currency) : "—"}
          </span>
          <span className="text-muted-foreground text-xs">
            a year{signOn > 0 && `, plus ${formatMoney(signOn, draft.currency)} on signing`}
          </span>
          {advertised.trim() && (
            <span className="text-faint text-xs">· the posting said {advertised.trim()}</span>
          )}
        </div>

        <div className="grid grid-cols-2 gap-3 @min-[30rem]:grid-cols-4">
          <Money label="Base" value={draft.baseAmount} onChange={(baseAmount) => set({ baseAmount })} />
          <Money label="Bonus" value={draft.bonusAmount} onChange={(bonusAmount) => set({ bonusAmount })} />
          <Money label="Equity a year" value={draft.equityAmount} onChange={(equityAmount) => set({ equityAmount })} />
          <Money label="Sign-on" value={draft.signOnAmount} onChange={(signOnAmount) => set({ signOnAmount })} />
        </div>

        <div className="grid grid-cols-1 gap-3 @min-[30rem]:grid-cols-3">
          <div className="space-y-1.5">
            <Label>Currency</Label>
            <Input
              value={draft.currency}
              onChange={(event) => set({ currency: event.target.value.toUpperCase().slice(0, 3) })}
              className="nums uppercase"
              aria-label="Currency"
            />
          </div>
          <div className="space-y-1.5">
            <Label>Answer by</Label>
            <DateField
              value={draft.respondBy}
              onChange={(respondBy) => set({ respondBy })}
              ariaLabel="Answer by"
              placeholder="No deadline"
              tone={days !== null && days < 0 ? "overdue" : undefined}
            />
            {days !== null && (
              <p className={cn("text-xs", days <= 2 ? "text-[var(--warning)]" : "text-muted-foreground")}>
                {days < 0
                  ? `${Math.abs(days)} days ago`
                  : days === 0
                    ? "Today"
                    : `${days} days left`}
              </p>
            )}
          </div>
          <div className="space-y-1.5">
            <Label>Starts</Label>
            <DateField
              value={draft.startsOn}
              onChange={(startsOn) => set({ startsOn })}
              ariaLabel="Start date"
              placeholder="Not agreed"
            />
          </div>
        </div>

        <div className="space-y-1.5">
          <Label>Vesting</Label>
          <Input
            value={draft.vesting}
            onChange={(event) => set({ vesting: event.target.value })}
            placeholder="4 years, 1 year cliff, monthly after"
          />
        </div>

        <div className="space-y-1.5">
          <Label>Terms</Label>
          <Textarea
            value={draft.terms}
            onChange={(event) => set({ terms: event.target.value })}
            placeholder="Everything the numbers can't hold: the percentage they quoted, relocation, a review at six months."
            className="min-h-20"
          />
        </div>

        <div className="flex flex-wrap items-center gap-2">
          <Button size="sm" variant={current ? "outline" : "default"} onClick={record} disabled={pending}>
            <PlusIcon />
            {current ? "Record a revision" : "Record this offer"}
          </Button>
          <p className="text-muted-foreground text-xs">
            {current
              ? "They came back with a better number? This keeps the first one on file."
              : "Nothing is saved until you record it."}
          </p>
        </div>

        {offers.length > 1 && (
          <div className="border-t pt-3">
            <p className="text-faint mb-2 text-[11px] font-medium tracking-wide uppercase">
              Earlier versions
            </p>
            <ul className="space-y-1">
              {offers.slice(1).map((offer) => (
                <li key={offer.id} className="group flex items-center justify-between gap-3 text-sm">
                  <span className="nums">
                    {formatMoney(
                      offer.baseAmount + offer.bonusAmount + offer.equityAmount,
                      offer.currency,
                    )}
                  </span>
                  <span className="text-muted-foreground flex items-center gap-2 text-xs">
                    {shortCivilDay(civilDay(new Date(offer.receivedAt), zone))}
                    <Button
                      variant="ghost"
                      size="icon-sm"
                      className="text-faint hover:text-destructive opacity-0 group-hover:opacity-100"
                      onClick={() => remove(offer.id)}
                      disabled={pending}
                      aria-label="Delete this version"
                    >
                      <Trash2Icon />
                    </Button>
                  </span>
                </li>
              ))}
            </ul>
          </div>
        )}
      </CardContent>
    </Card>
  );
}

function Money({
  label,
  value,
  onChange,
}: {
  label: string;
  value: string;
  onChange: (value: string) => void;
}) {
  return (
    <div className="space-y-1.5">
      <Label>{label}</Label>
      <Input
        value={value}
        onChange={(event) => onChange(event.target.value)}
        inputMode="numeric"
        placeholder="0"
        className="nums"
        aria-label={label}
      />
    </div>
  );
}