|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { Plus, Trash2 } from "lucide-react"; |
| 4 | +import { useFieldArray, useFormContext, useWatch } from "react-hook-form"; |
| 5 | +import { Button } from "@/components/ui/button"; |
| 6 | +import { Input } from "@/components/ui/input"; |
| 7 | +import { Label } from "@/components/ui/label"; |
| 8 | +import { |
| 9 | + Select, |
| 10 | + SelectContent, |
| 11 | + SelectItem, |
| 12 | + SelectTrigger, |
| 13 | + SelectValue, |
| 14 | +} from "@/components/ui/select"; |
| 15 | +import type { BudgetRow } from "@/lib/proposal-template-schemas"; |
| 16 | + |
| 17 | +export interface BudgetRepeaterProps { |
| 18 | + /** Dotted path within the form state, e.g. "budget" or "templateFields.budget". */ |
| 19 | + name: string; |
| 20 | + /** Per-row error accessor. */ |
| 21 | + getRowError?: (index: number) => { label?: string; amount?: string } | undefined; |
| 22 | + /** Top-level error (e.g. "add at least one line"). */ |
| 23 | + topLevelError?: string; |
| 24 | +} |
| 25 | + |
| 26 | +const CURRENCIES = ["ETH", "USDC"] as const; |
| 27 | + |
| 28 | +export function BudgetRepeater({ name, getRowError, topLevelError }: BudgetRepeaterProps) { |
| 29 | + const { control, register } = useFormContext(); |
| 30 | + const { fields, append, remove } = useFieldArray({ control, name }); |
| 31 | + const rows = (useWatch({ control, name }) as BudgetRow[] | undefined) ?? []; |
| 32 | + |
| 33 | + const totals = new Map<"ETH" | "USDC", number>(); |
| 34 | + for (const r of rows) { |
| 35 | + if (!r || typeof r.amount !== "number" || r.amount <= 0) continue; |
| 36 | + if (!r.label?.trim?.()) continue; |
| 37 | + const cur = (r.currency ?? "ETH") as "ETH" | "USDC"; |
| 38 | + totals.set(cur, (totals.get(cur) ?? 0) + r.amount); |
| 39 | + } |
| 40 | + const totalsStr = |
| 41 | + totals.size === 0 |
| 42 | + ? "—" |
| 43 | + : [...totals.entries()] |
| 44 | + .sort(([a], [b]) => (a < b ? -1 : 1)) |
| 45 | + .map(([cur, sum]) => `${Number(sum.toFixed(6))} ${cur}`) |
| 46 | + .join(" · "); |
| 47 | + |
| 48 | + return ( |
| 49 | + <div className="space-y-3"> |
| 50 | + {fields.length === 0 ? ( |
| 51 | + <p className="text-sm text-muted-foreground italic"> |
| 52 | + No budget lines yet. Add one to request funding. |
| 53 | + </p> |
| 54 | + ) : ( |
| 55 | + <div className="space-y-2"> |
| 56 | + {fields.map((field, index) => { |
| 57 | + const rowErr = getRowError?.(index); |
| 58 | + return ( |
| 59 | + <div |
| 60 | + key={field.id} |
| 61 | + className="grid grid-cols-[1fr_140px_110px_auto] gap-2 items-start" |
| 62 | + > |
| 63 | + <div> |
| 64 | + <Input |
| 65 | + placeholder="Line item (e.g. Sponsorship fee)" |
| 66 | + aria-label={`Budget line ${index + 1} label`} |
| 67 | + {...register(`${name}.${index}.label` as const)} |
| 68 | + /> |
| 69 | + {rowErr?.label ? ( |
| 70 | + <p className="text-xs text-red-500 mt-1">{rowErr.label}</p> |
| 71 | + ) : null} |
| 72 | + </div> |
| 73 | + <div> |
| 74 | + <Input |
| 75 | + type="number" |
| 76 | + step="any" |
| 77 | + min={0} |
| 78 | + placeholder="Amount" |
| 79 | + aria-label={`Budget line ${index + 1} amount`} |
| 80 | + {...register(`${name}.${index}.amount` as const, { valueAsNumber: true })} |
| 81 | + /> |
| 82 | + {rowErr?.amount ? ( |
| 83 | + <p className="text-xs text-red-500 mt-1">{rowErr.amount}</p> |
| 84 | + ) : null} |
| 85 | + </div> |
| 86 | + <CurrencySelect name={`${name}.${index}.currency` as const} /> |
| 87 | + <Button |
| 88 | + type="button" |
| 89 | + size="icon" |
| 90 | + variant="ghost" |
| 91 | + aria-label={`Remove budget line ${index + 1}`} |
| 92 | + onClick={() => remove(index)} |
| 93 | + > |
| 94 | + <Trash2 className="h-4 w-4" /> |
| 95 | + </Button> |
| 96 | + </div> |
| 97 | + ); |
| 98 | + })} |
| 99 | + </div> |
| 100 | + )} |
| 101 | + |
| 102 | + <div className="flex items-center justify-between pt-1"> |
| 103 | + <Button |
| 104 | + type="button" |
| 105 | + size="sm" |
| 106 | + variant="outline" |
| 107 | + onClick={() => append({ label: "", amount: 0, currency: "ETH" })} |
| 108 | + > |
| 109 | + <Plus className="h-4 w-4 mr-1" /> |
| 110 | + Add line |
| 111 | + </Button> |
| 112 | + <div className="text-sm"> |
| 113 | + <span className="text-muted-foreground mr-1">Total:</span> |
| 114 | + <span className="font-semibold tabular-nums">{totalsStr}</span> |
| 115 | + </div> |
| 116 | + </div> |
| 117 | + |
| 118 | + {topLevelError ? <p className="text-xs text-red-500">{topLevelError}</p> : null} |
| 119 | + </div> |
| 120 | + ); |
| 121 | +} |
| 122 | + |
| 123 | +function CurrencySelect({ name }: { name: string }) { |
| 124 | + const { setValue, control } = useFormContext(); |
| 125 | + const value = useWatch({ control, name }) as string | undefined; |
| 126 | + return ( |
| 127 | + <div> |
| 128 | + <Label className="sr-only" htmlFor={name}> |
| 129 | + Currency |
| 130 | + </Label> |
| 131 | + <Select |
| 132 | + value={value ?? "ETH"} |
| 133 | + onValueChange={(v) => setValue(name, v, { shouldDirty: true })} |
| 134 | + > |
| 135 | + <SelectTrigger id={name}> |
| 136 | + <SelectValue /> |
| 137 | + </SelectTrigger> |
| 138 | + <SelectContent> |
| 139 | + {CURRENCIES.map((c) => ( |
| 140 | + <SelectItem key={c} value={c}> |
| 141 | + {c} |
| 142 | + </SelectItem> |
| 143 | + ))} |
| 144 | + </SelectContent> |
| 145 | + </Select> |
| 146 | + </div> |
| 147 | + ); |
| 148 | +} |
0 commit comments