`offerableCosts` offered every non-archived anchored cost whatever the date, so the default new-purchase flow — month-only, since `emptyDraft` seeds `date: monthId` — offered the owner a choice that now fails at the boundary. An anchored cost needs a day, the same way `newCostOptionIsSelectable` already gates the new-cost pairings. A calendar cost is unaffected: its charge names the slot it settles and its own date is never stepped from. An anchored cost stays SELECTED when the date drops to month-only, because a selection is never taken away out from under the owner — so the dialog says what is wrong there rather than letting Save be the first place it comes up. Two more in the same file and its neighbour: - A recurring cost that has vanished since the dialog opened (deleted from Settings in another tab) made the purchase save with no association and no message, leaving the bill still showing as expected. It is refused now. - The comment above `save()` described `requireConfirmationAdvances`, deleted in this branch. A write can still be refused; only the stated reason was stale. `normalizedStartDateFor` and `isValidStartDate` both asked their own copy of the granularity rule, and the first had `anchoring` optional — which the unit handler omitted, silently answering "calendar" for a cost anchored to its last charge. Both call the domain predicate now, with anchoring required. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
636 lines
24 KiB
TypeScript
636 lines
24 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { NativeSelect } from "@/components/ui/native-select";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Segmented } from "./Segmented.tsx";
|
|
import { SplitEditor } from "./SplitEditor.tsx";
|
|
import { PlanEditor } from "./PlanEditor.tsx";
|
|
import { BulkLines } from "./BulkLines.tsx";
|
|
import {
|
|
emptyDraft,
|
|
fromPurchase,
|
|
toPurchase,
|
|
validatePurchase,
|
|
type PurchaseDraft,
|
|
} from "../purchaseForm.ts";
|
|
import {
|
|
emptyBulkDraft,
|
|
toPurchases,
|
|
validateBulk,
|
|
type BulkDraft,
|
|
} from "../bulkEntry.ts";
|
|
import { useDataset } from "../hooks/useDataset.ts";
|
|
import { parseMoneyInput } from "../moneyInput.ts";
|
|
import { useMutate } from "../hooks/useMutate.ts";
|
|
import {
|
|
addPurchase,
|
|
addRecurringCost,
|
|
cancelScheduleFrom,
|
|
updatePurchase,
|
|
} from "../../store/actions.ts";
|
|
import {
|
|
newCostOptionIsSelectable,
|
|
offerableCosts,
|
|
seedCostFromDraft,
|
|
sourceFor,
|
|
} from "../recurringForm.ts";
|
|
import { WEEKDAYS } from "../routes/settings/RecurringSection.tsx";
|
|
import type { Anchoring, MonthId, Purchase, Recurrence } from "../../domain/types.ts";
|
|
|
|
/** The "Recurring cost" select's own sentinel, alongside "none" and a cost id. */
|
|
const NEW_COST = "__new__";
|
|
|
|
interface Props {
|
|
monthId: MonthId;
|
|
/** Provide to edit an existing purchase; omit to add a new one. */
|
|
purchase?: Purchase;
|
|
trigger: React.ReactNode;
|
|
}
|
|
|
|
export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
|
const dataset = useDataset();
|
|
const { mutate, error, clearError } = useMutate();
|
|
const activePosts = dataset.posts
|
|
.filter((p) => !p.archived)
|
|
.sort((a, b) => a.order - b.order);
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [draft, setDraft] = useState<PurchaseDraft>(() =>
|
|
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
|
);
|
|
// The amount field keeps its own text so a half-typed "30." or "30 " stays
|
|
// exactly as typed; `draft.amount` only moves when the text parses.
|
|
const [amountText, setAmountText] = useState(() =>
|
|
purchase ? String(purchase.total.amount) : "",
|
|
);
|
|
// "many" is only meaningful when adding — editing an existing purchase edits
|
|
// exactly one, so the toggle is hidden in that case.
|
|
const [mode, setMode] = useState<"one" | "many">("one");
|
|
const [bulk, setBulk] = useState<BulkDraft>(() =>
|
|
emptyBulkDraft(monthId, activePosts[0]?.id ?? ""),
|
|
);
|
|
const bulkMode = !purchase && mode === "many";
|
|
|
|
// "none" | a RecurringCostId | NEW_COST. Seeded from the purchase's own
|
|
// association when editing, so the control shows what is already there.
|
|
const [recurringSelection, setRecurringSelection] = useState<string>(
|
|
() => purchase?.source?.recurringId ?? "none",
|
|
);
|
|
// The shapes NEW_COST reveals, same defaults RecurringSection's own
|
|
// "Add recurring cost" quick-add uses.
|
|
const [newRecurrence, setNewRecurrence] = useState<Recurrence>({
|
|
kind: "everyNMonths",
|
|
n: 1,
|
|
});
|
|
const [newAnchoring, setNewAnchoring] = useState<Anchoring>("calendar");
|
|
|
|
const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft);
|
|
|
|
// The costs offerable for the date currently typed, plus whatever is
|
|
// already selected — so picking a cost, then changing the date to one its
|
|
// grid does not cover, does not silently drop the selection out of the
|
|
// list out from under the owner.
|
|
const offerable = offerableCosts(dataset, draft.date);
|
|
const selected = dataset.recurring.find((cost) => cost.id === recurringSelection);
|
|
const selectableCosts =
|
|
selected && !offerable.some((cost) => cost.id === selected.id)
|
|
? [...offerable, selected]
|
|
: offerable;
|
|
|
|
function reset() {
|
|
setDraft(
|
|
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
|
);
|
|
setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? ""));
|
|
setRecurringSelection(purchase?.source?.recurringId ?? "none");
|
|
setNewRecurrence({ kind: "everyNMonths", n: 1 });
|
|
setNewAnchoring("calendar");
|
|
}
|
|
|
|
// A write can be REFUSED, not merely fail on a full disk. Tagging a
|
|
// purchase to a cost anchored to its last charge is rejected when the
|
|
// purchase's date carries no day, because that date is what the next charge
|
|
// is measured from; a cost that has vanished since the dialog opened is
|
|
// rejected below; and any of the store's other pairing rules can refuse
|
|
// too. Closing the dialog regardless would show the user a save that
|
|
// silently did nothing, so the dialog stays open and shows `error` instead.
|
|
// `mutate` never rejects.
|
|
function save() {
|
|
if (errors.length > 0) return;
|
|
// One mutate for the whole batch: a single write, one queue entry, and no
|
|
// way to half-commit a shopping trip. Creating a NEW recurring cost is
|
|
// part of that same batch — the cost and its first charge must land
|
|
// together, or a failure could leave a cost with no first charge.
|
|
const created = bulkMode ? toPurchases(bulk) : null;
|
|
void mutate((data) => {
|
|
if (created) {
|
|
for (const p of created) addPurchase(data, p);
|
|
return;
|
|
}
|
|
let source: Purchase["source"];
|
|
if (recurringSelection === NEW_COST) {
|
|
const seeded = addRecurringCost(
|
|
data,
|
|
seedCostFromDraft(draft, newRecurrence, newAnchoring),
|
|
);
|
|
source = sourceFor(data, seeded, draft.date);
|
|
} else if (recurringSelection !== "none") {
|
|
const cost = data.recurring.find((c) => c.id === recurringSelection);
|
|
// Throw rather than save untagged. The cost can genuinely be gone —
|
|
// deleted from Settings in another tab while this dialog sat open —
|
|
// and dropping the association silently would save a purchase the
|
|
// owner believes is a charge for a bill, leaving that bill still
|
|
// showing as expected. `useMutate` catches this and the dialog stays
|
|
// open with the message.
|
|
if (!cost) {
|
|
throw new Error(
|
|
`The recurring cost this purchase was tagged to no longer exists. Choose another one ` +
|
|
`(or "None") and save again.`,
|
|
);
|
|
}
|
|
source = sourceFor(data, cost, draft.date);
|
|
}
|
|
if (purchase) {
|
|
// `source` is passed explicitly, even when undefined: updatePurchase
|
|
// only touches keys present in `changes`, so omitting it entirely
|
|
// when the owner picks "None" would leave a stored association
|
|
// untouched instead of clearing it.
|
|
updatePurchase(data, purchase.id, { ...toPurchase(draft), source });
|
|
} else {
|
|
addPurchase(data, { ...toPurchase(draft), ...(source ? { source } : {}) });
|
|
}
|
|
}).then((saved) => {
|
|
if (!saved) return;
|
|
setOpen(false);
|
|
if (!purchase) reset();
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (next) {
|
|
reset();
|
|
clearError();
|
|
}
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{purchase ? "Edit purchase" : bulkMode ? "Add many purchases" : "Add purchase"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{!purchase && (
|
|
<Segmented
|
|
label="How many purchases to add"
|
|
value={mode}
|
|
onChange={setMode}
|
|
options={[
|
|
{ value: "one", label: "One purchase" },
|
|
{ value: "many", label: "Many lines" },
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{bulkMode ? (
|
|
<>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="bulk-date">
|
|
{bulk.date.length > 7 ? "Date" : "Month"}
|
|
</Label>
|
|
<Input
|
|
id="bulk-date"
|
|
type={bulk.date.length > 7 ? "date" : "text"}
|
|
readOnly={bulk.date.length <= 7}
|
|
className={bulk.date.length > 7 ? undefined : "font-money"}
|
|
value={bulk.date}
|
|
onChange={(event) => {
|
|
const date = event.target.value;
|
|
setBulk((b) => ({ ...b, date }));
|
|
}}
|
|
/>
|
|
<DateModeToggle
|
|
onClick={() =>
|
|
setBulk((b) => ({
|
|
...b,
|
|
date: b.date.length > 7 ? b.date.slice(0, 7) : `${b.date}-01`,
|
|
}))
|
|
}
|
|
>
|
|
{bulk.date.length > 7 ? "use the month only" : "set exact date"}
|
|
</DateModeToggle>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="bulk-post">Post</Label>
|
|
<NativeSelect
|
|
id="bulk-post"
|
|
value={bulk.postId}
|
|
onChange={(event) => {
|
|
const postId = event.target.value;
|
|
setBulk((b) => ({ ...b, postId }));
|
|
}}
|
|
>
|
|
<option value="">Choose a post…</option>
|
|
{activePosts.map((post) => (
|
|
<option key={post.id} value={post.id}>
|
|
{post.name}
|
|
</option>
|
|
))}
|
|
</NativeSelect>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="bulk-currency">Currency</Label>
|
|
<NativeSelect
|
|
id="bulk-currency"
|
|
value={bulk.currency}
|
|
onChange={(event) => {
|
|
const currency = event.target.value as typeof bulk.currency;
|
|
setBulk((b) => ({ ...b, currency }));
|
|
}}
|
|
>
|
|
{dataset.currencies.map(({ code: currency }) => (
|
|
<option key={currency} value={currency}>
|
|
{currency}
|
|
</option>
|
|
))}
|
|
</NativeSelect>
|
|
</div>
|
|
</div>
|
|
|
|
<BulkLines draft={bulk} onChange={setBulk} />
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Each line is saved as its own purchase in the post above. Need a
|
|
split or a payment plan on one of them? Save it here, then open
|
|
it from the month view.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="purchase-date">
|
|
{draft.date.length > 7 ? "Date" : "Month"}
|
|
</Label>
|
|
{draft.date.length > 7 ? (
|
|
<>
|
|
<Input
|
|
id="purchase-date"
|
|
type="date"
|
|
value={draft.date}
|
|
onChange={(event) => {
|
|
const date = event.target.value;
|
|
setDraft((d) => ({ ...d, date }));
|
|
}}
|
|
/>
|
|
<DateModeToggle
|
|
onClick={() => {
|
|
const monthOnly = draft.date.slice(0, 7);
|
|
setDraft((d) => ({ ...d, date: monthOnly }));
|
|
// Dropping to month-only can strand a "+ New recurring
|
|
// cost…" selection that needed the day just removed
|
|
// (e.g. "days", or "months" anchored to the last
|
|
// charge) — its option would show `disabled` below but
|
|
// stay selected, so reset to the one pairing that is
|
|
// always valid rather than leave state pointing at a
|
|
// choice the owner can no longer make.
|
|
if (!newCostOptionIsSelectable(newRecurrence.kind, newAnchoring, monthOnly)) {
|
|
setNewRecurrence((r) => ({ kind: "everyNMonths", n: r.n }));
|
|
setNewAnchoring("calendar");
|
|
}
|
|
}}
|
|
>
|
|
use the month only
|
|
</DateModeToggle>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Input id="purchase-date" value={draft.date} readOnly className="font-money" />
|
|
<DateModeToggle
|
|
onClick={() => setDraft((d) => ({ ...d, date: `${d.date}-01` }))}
|
|
>
|
|
set exact date
|
|
</DateModeToggle>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="purchase-description">Description</Label>
|
|
<Input
|
|
id="purchase-description"
|
|
value={draft.description}
|
|
onChange={(event) =>
|
|
setDraft({ ...draft, description: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="purchase-amount">Total</Label>
|
|
{/* type="text", not "number": a number input silently discards
|
|
anything non-numeric, so "30$" would never reach the parser.
|
|
Entry speed is the point — the currency is set from what was
|
|
typed rather than from a second control. */}
|
|
<Input
|
|
id="purchase-amount"
|
|
inputMode="decimal"
|
|
className="font-money"
|
|
placeholder="30$"
|
|
value={amountText}
|
|
onChange={(event) => {
|
|
const typed = event.target.value;
|
|
setAmountText(typed);
|
|
const parsed = parseMoneyInput(typed, dataset.currencies, draft.currency);
|
|
if (parsed) {
|
|
setDraft({ ...draft, amount: parsed.amount, currency: parsed.currency });
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="purchase-currency">Currency</Label>
|
|
<NativeSelect
|
|
id="purchase-currency"
|
|
value={draft.currency}
|
|
onChange={(event) =>
|
|
setDraft({ ...draft, currency: event.target.value as typeof draft.currency })
|
|
}
|
|
>
|
|
{dataset.currencies.map(({ code: currency }) => (
|
|
<option key={currency} value={currency}>
|
|
{currency}
|
|
</option>
|
|
))}
|
|
</NativeSelect>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="purchase-note">Note (optional)</Label>
|
|
<Input
|
|
id="purchase-note"
|
|
placeholder="Anything worth remembering about this one"
|
|
value={draft.note}
|
|
onChange={(event) => {
|
|
const note = event.target.value;
|
|
setDraft((d) => ({ ...d, note }));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="purchase-recurring">Recurring cost</Label>
|
|
<NativeSelect
|
|
id="purchase-recurring"
|
|
value={recurringSelection}
|
|
onChange={(event) => {
|
|
const value = event.target.value;
|
|
setRecurringSelection(value);
|
|
}}
|
|
>
|
|
<option value="none">None</option>
|
|
{selectableCosts.map((cost) => (
|
|
<option key={cost.id} value={cost.id}>
|
|
{cost.name}
|
|
</option>
|
|
))}
|
|
<option value={NEW_COST}>+ New recurring cost…</option>
|
|
</NativeSelect>
|
|
<p className="text-xs text-budget-ink-muted">
|
|
Tag this purchase as a charge for one of your bills: for one
|
|
measured from the last charge, that also restarts its cycle
|
|
from today.
|
|
</p>
|
|
{/* An anchored cost stays SELECTED when the date drops to
|
|
month-only (the selection is never taken away out from under
|
|
the owner), but the store will refuse the save: that date is
|
|
what the bill's next charge is measured from. Say so here
|
|
rather than letting Save be the first place it comes up. */}
|
|
{selected?.anchoring === "lastCharge" && draft.date.length <= 7 && (
|
|
<p className="text-xs text-destructive">
|
|
“{selected.name}” is measured from its last charge, so this
|
|
purchase needs an exact date — use “set exact date” above.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{recurringSelection === NEW_COST && (
|
|
<fieldset className="space-y-3 rounded-md border border-budget-rule p-3">
|
|
<legend className="px-1 text-xs font-medium text-budget-ink-muted">
|
|
New recurring cost
|
|
</legend>
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="new-recurring-interval">Every</Label>
|
|
<Input
|
|
id="new-recurring-interval"
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
className="h-8 w-20"
|
|
value={newRecurrence.n}
|
|
onChange={(event) => {
|
|
// Captured before setState, same discipline mutate
|
|
// callbacks need — this one is not deferred, but there
|
|
// is no reason to read the live DOM node twice.
|
|
const typed = event.target.value;
|
|
const n = Number(typed);
|
|
if (!Number.isInteger(n) || n < 1) return;
|
|
setNewRecurrence((r) => ({ ...r, n }));
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="new-recurring-unit">Unit</Label>
|
|
<NativeSelect
|
|
id="new-recurring-unit"
|
|
className="h-8 w-auto"
|
|
value={newRecurrence.kind}
|
|
onChange={(event) => {
|
|
const kind = event.target.value as Recurrence["kind"];
|
|
setNewRecurrence((r) =>
|
|
kind === "everyNWeeks"
|
|
? { kind, n: r.n, weekday: 1 }
|
|
: { kind, n: r.n },
|
|
);
|
|
}}
|
|
>
|
|
<option
|
|
value="everyNDays"
|
|
disabled={!newCostOptionIsSelectable("everyNDays", newAnchoring, draft.date)}
|
|
>
|
|
days
|
|
</option>
|
|
<option
|
|
value="everyNWeeks"
|
|
disabled={!newCostOptionIsSelectable("everyNWeeks", newAnchoring, draft.date)}
|
|
>
|
|
weeks
|
|
</option>
|
|
<option
|
|
value="everyNMonths"
|
|
disabled={
|
|
!newCostOptionIsSelectable("everyNMonths", newAnchoring, draft.date)
|
|
}
|
|
>
|
|
months
|
|
</option>
|
|
</NativeSelect>
|
|
</div>
|
|
{newRecurrence.kind === "everyNWeeks" && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="new-recurring-weekday">Weekday</Label>
|
|
<NativeSelect
|
|
id="new-recurring-weekday"
|
|
className="h-8 w-auto"
|
|
value={newRecurrence.weekday}
|
|
onChange={(event) => {
|
|
const weekday = Number(event.target.value);
|
|
setNewRecurrence((r) =>
|
|
r.kind === "everyNWeeks" ? { ...r, weekday } : r,
|
|
);
|
|
}}
|
|
>
|
|
{WEEKDAYS.map((day, index) => (
|
|
<option key={day} value={index}>
|
|
{day}
|
|
</option>
|
|
))}
|
|
</NativeSelect>
|
|
</div>
|
|
)}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="new-recurring-anchoring">Measured from</Label>
|
|
<NativeSelect
|
|
id="new-recurring-anchoring"
|
|
className="h-8 w-auto"
|
|
value={newAnchoring}
|
|
onChange={(event) => {
|
|
const anchoring = event.target.value as Anchoring;
|
|
setNewAnchoring(anchoring);
|
|
}}
|
|
>
|
|
<option
|
|
value="calendar"
|
|
disabled={!newCostOptionIsSelectable(newRecurrence.kind, "calendar", draft.date)}
|
|
>
|
|
the calendar
|
|
</option>
|
|
<option
|
|
value="lastCharge"
|
|
disabled={
|
|
!newCostOptionIsSelectable(newRecurrence.kind, "lastCharge", draft.date)
|
|
}
|
|
>
|
|
the last charge
|
|
</option>
|
|
</NativeSelect>
|
|
</div>
|
|
</div>
|
|
{draft.date.length <= 7 && (
|
|
<p className="text-xs text-budget-ink-muted">
|
|
Days, weeks, and "the last charge" need an exact date — use
|
|
"set exact date" next to the date above, then pick the unit
|
|
or anchoring you want.
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-budget-ink-muted">
|
|
This purchase becomes its first charge. Anchored to the last
|
|
charge, its cycle restarts from today rather than from a fixed
|
|
grid.
|
|
</p>
|
|
</fieldset>
|
|
)}
|
|
|
|
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
|
|
|
|
<PlanEditor draft={draft} onChange={setDraft} />
|
|
</>
|
|
)}
|
|
|
|
{purchase?.schedule && !purchase.schedule.cancelledFromMonth && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
mutate((data) => cancelScheduleFrom(data, purchase.id, monthId));
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
Cancel remaining slices from {monthId}
|
|
</Button>
|
|
)}
|
|
|
|
{errors.length > 0 && (
|
|
<ul className="space-y-1 text-sm text-destructive">
|
|
{errors.map((message) => (
|
|
<li key={message}>{message}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{error && (
|
|
<p role="alert" className="text-sm text-destructive">
|
|
Could not save: {error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={save} disabled={errors.length > 0}>
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "set exact date" / "use the month only", under the date field.
|
|
*
|
|
* Was a bare `<button>` with no padding at all: a 15px-tall hit target under a
|
|
* 36px field, which is under the 24px minimum and impossible to hit on a
|
|
* phone. `size="xs"` gives it a real 24px box, and the negative left margin
|
|
* cancels the button's own padding so the text still lines up with the field's
|
|
* left edge rather than sitting 8px in from it.
|
|
*/
|
|
function DateModeToggle({
|
|
onClick,
|
|
children,
|
|
}: {
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
className="-ml-2 font-normal text-muted-foreground underline decoration-dotted underline-offset-2"
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</Button>
|
|
);
|
|
}
|