| 1 | import { Link, useFetcher } from "react-router"; |
| 2 | import { useEffect, useId, useState } from "react"; |
| 3 | |
| 4 | import { APPETITES, HORIZONS, type Horizon, type WorkItem, appetiteLetter, horizonLabel } from "@g1t/contracts"; |
| 5 | |
| 6 | import { AppetiteChip } from "./plan"; |
| 7 | import { ErrorText } from "./ui"; |
| 8 | import { Input, InputAddon, InputGroup } from "./ui/input"; |
| 9 | import { Badge } from "./ui/badge"; |
| 10 | import { Button } from "./ui/button"; |
| 11 | import { SelectField } from "./ui/select"; |
| 12 | import { Switch } from "./ui/switch"; |
| 13 | import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; |
| 14 | import { Hint } from "./ui/hint"; |
| 15 | import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, budgetText, planPath } from "../lib/plan"; |
| 16 | |
| 17 | /** |
| 18 | * The item page's Plan, in its sidebar beside Milestone: where it stands |
| 19 | * in the plan (a horizon), its appetite, a budget in dollars for an agent |
| 20 | * working on it, and urgent, which puts it first in Now. Each change posts |
| 21 | * `action=plan` to the page. Read-only for who cannot plan (`canEdit`, the |
| 22 | * Triage role); Now and urgent also take the right to manage milestones |
| 23 | * (`canNow`), which the work service checks again. |
| 24 | */ |
| 25 | export function PlanBox({ |
| 26 | item, |
| 27 | workspace, |
| 28 | canEdit, |
| 29 | canNow, |
| 30 | }: { |
| 31 | item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state">; |
| 32 | workspace: string; |
| 33 | canEdit: boolean; |
| 34 | canNow: boolean; |
| 35 | }) { |
| 36 | const fetcher = useFetcher<{ error?: string } | null>(); |
| 37 | const budgetId = useId(); |
| 38 | const editable = canEdit && item.state === "open"; |
| 39 | // While saving, what was asked for. |
| 40 | const posted = fetcher.state !== "idle" ? fetcher.formData : null; |
| 41 | const horizon = (posted?.get("urgent") === "1" ? "now" : (posted?.get("horizon") as Horizon | null)) ?? item.horizon; |
| 42 | const appetite = posted?.has("appetite") ? (posted.get("appetite") === "none" ? null : (posted.get("appetite") as WorkItem["appetite"])) : item.appetite; |
| 43 | const urgent = posted?.has("urgent") ? posted.get("urgent") === "1" : item.urgent; |
| 44 | const [budget, setBudget] = useState(item.budgetUsd == null ? "" : String(item.budgetUsd)); |
| 45 | useEffect(() => setBudget(item.budgetUsd == null ? "" : String(item.budgetUsd)), [item.budgetUsd]); |
| 46 | const post = (fields: Record<string, string>) => fetcher.submit({ action: "plan", ...fields }, { method: "post" }); |
| 47 | const lockedInNow = horizon === "now" && !canNow; |
| 48 | return ( |
| 49 | <section> |
| 50 | <div className="flex items-center justify-between gap-2"> |
| 51 | <h3 className="text-sm font-medium">Plan</h3> |
| 52 | <Link to={planPath(workspace)} className="text-xs text-faint hover:text-fg"> |
| 53 | Open the plan |
| 54 | </Link> |
| 55 | </div> |
| 56 | {editable ? ( |
| 57 | <div className="mt-2 space-y-3"> |
| 58 | <SelectField |
| 59 | aria-label="Horizon" |
| 60 | size="sm" |
| 61 | value={horizon} |
| 62 | disabled={lockedInNow} |
| 63 | onValueChange={(value) => value !== horizon && post({ horizon: value })} |
| 64 | options={HORIZONS.map((one) => ({ |
| 65 | value: one, |
| 66 | label: horizonLabel(one), |
| 67 | description: HORIZON_HINTS[one], |
| 68 | disabled: one === "now" && !canNow, |
| 69 | }))} |
| 70 | /> |
| 71 | <div className="flex items-center justify-between gap-2"> |
| 72 | <Hint label={APPETITE_SUMMARY}> |
| 73 | <span className="text-xs text-muted underline decoration-line-strong decoration-dotted underline-offset-4">Appetite</span> |
| 74 | </Hint> |
| 75 | <ToggleGroup |
| 76 | type="single" |
| 77 | variant="outline" |
| 78 | size="sm" |
| 79 | aria-label="Appetite" |
| 80 | value={appetite ?? ""} |
| 81 | onValueChange={(value) => post({ appetite: value || "none" })} |
| 82 | > |
| 83 | {APPETITES.map((one) => ( |
| 84 | <ToggleGroupItem key={one} value={one} aria-label={APPETITE_HINTS[one]} className="font-mono"> |
| 85 | {appetiteLetter(one)} |
| 86 | </ToggleGroupItem> |
| 87 | ))} |
| 88 | </ToggleGroup> |
| 89 | </div> |
| 90 | <fetcher.Form method="post" className="flex items-center gap-2"> |
| 91 | <input type="hidden" name="action" value="plan" /> |
| 92 | <label htmlFor={budgetId} className="grow text-xs text-muted"> |
| 93 | Budget |
| 94 | </label> |
| 95 | <InputGroup className="h-8 w-24"> |
| 96 | <InputAddon className="pr-0 text-xs">$</InputAddon> |
| 97 | <Input id={budgetId} name="budget" inputMode="decimal" value={budget} onChange={(event) => setBudget(event.target.value)} className="pl-1.5" /> |
| 98 | </InputGroup> |
| 99 | <Button type="submit" size="sm" variant="outline" disabled={budget === (item.budgetUsd == null ? "" : String(item.budgetUsd))}> |
| 100 | Save |
| 101 | </Button> |
| 102 | </fetcher.Form> |
| 103 | <label className="flex items-center justify-between gap-2"> |
| 104 | <span className="min-w-0"> |
| 105 | <span className="block text-xs text-muted">Urgent</span> |
| 106 | <span className="block text-[0.6875rem] text-faint">First in Now, until it closes</span> |
| 107 | </span> |
| 108 | <Switch size="sm" checked={urgent} disabled={!canNow} onCheckedChange={(on) => post({ urgent: on ? "1" : "0" })} aria-label="Urgent" /> |
| 109 | </label> |
| 110 | {lockedInNow && <p className="text-xs text-faint">Moving it out of Now takes the right to manage milestones.</p>} |
| 111 | </div> |
| 112 | ) : ( |
| 113 | <div className="mt-2 flex flex-wrap items-center gap-1.5 text-sm"> |
| 114 | {item.urgent && <Badge tone="danger">Urgent</Badge>} |
| 115 | <span>{horizonLabel(item.horizon)}</span> |
| 116 | {item.appetite && <AppetiteChip appetite={item.appetite} />} |
| 117 | {item.budgetUsd != null && <span className="text-xs text-muted">{budgetText(item.budgetUsd)} budget</span>} |
| 118 | </div> |
| 119 | )} |
| 120 | {fetcher.state === "idle" && fetcher.data?.error && ( |
| 121 | <div className="mt-2"> |
| 122 | <ErrorText>{fetcher.data.error}</ErrorText> |
| 123 | </div> |
| 124 | )} |
| 125 | </section> |
| 126 | ); |
| 127 | } |