748 lines
22 KiB
TypeScript
748 lines
22 KiB
TypeScript
// web/src/pages/settings/PlansPage.tsx
|
||
import {
|
||
useMemo,
|
||
useState,
|
||
useEffect,
|
||
type FormEvent,
|
||
type ReactNode,
|
||
} from "react";
|
||
import { useDashboard } from "../../hooks/useDashboard";
|
||
import SettingsNav from "./_SettingsNav";
|
||
import {
|
||
useCreatePlan,
|
||
useUpdatePlan,
|
||
useDeletePlan,
|
||
} from "../../hooks/useFixedPlans";
|
||
import { Money } from "../../components/ui";
|
||
import { useToast } from "../../components/Toast";
|
||
import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone";
|
||
|
||
function daysUntil(iso: string, userTimezone: string) {
|
||
const today = getTodayInTimezone(userTimezone);
|
||
const due = isoToDateString(iso, userTimezone);
|
||
const todayDate = new Date(today);
|
||
const dueDate = new Date(due);
|
||
const diffMs = dueDate.getTime() - todayDate.getTime();
|
||
return Math.round(diffMs / (24 * 60 * 60 * 1000));
|
||
}
|
||
|
||
function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) {
|
||
const d = daysUntil(dueISO, userTimezone);
|
||
if (d < 0)
|
||
return (
|
||
<span
|
||
className="badge"
|
||
style={{ borderColor: "#7f1d1d" }}
|
||
>
|
||
Overdue
|
||
</span>
|
||
);
|
||
if (d <= 7) return <span className="badge">Due in {d}d</span>;
|
||
return (
|
||
<span className="badge" aria-hidden="true">
|
||
On track
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function SettingsPlansPage() {
|
||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||
const createM = useCreatePlan();
|
||
const updateM = useUpdatePlan();
|
||
const deleteM = useDeletePlan();
|
||
const { push } = useToast();
|
||
|
||
// Get user timezone from dashboard data
|
||
const userTimezone = data?.user?.timezone || getBrowserTimezone();
|
||
|
||
// Add form state
|
||
const [name, setName] = useState("");
|
||
const [total, setTotal] = useState("");
|
||
const [funded, setFunded] = useState("");
|
||
const [priority, setPriority] = useState("");
|
||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||
|
||
// Auto-payment form state
|
||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||
const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly");
|
||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
||
const [dayOfWeek, setDayOfWeek] = useState(0);
|
||
const [everyNDays, setEveryNDays] = useState(30);
|
||
const [minFundingPercent, setMinFundingPercent] = useState(100);
|
||
|
||
const totals = useMemo(() => {
|
||
if (!data) return { funded: 0, total: 0, remaining: 0 };
|
||
const funded = data.fixedPlans.reduce(
|
||
(s, p) => s + p.fundedCents,
|
||
0,
|
||
);
|
||
const total = data.fixedPlans.reduce(
|
||
(s, p) => s + p.totalCents,
|
||
0,
|
||
);
|
||
return {
|
||
funded,
|
||
total,
|
||
remaining: Math.max(0, total - funded),
|
||
};
|
||
}, [data]);
|
||
|
||
const overallPctFunded = useMemo(() => {
|
||
if (!totals.total) return 0;
|
||
return Math.round((totals.funded / totals.total) * 100);
|
||
}, [totals.funded, totals.total]);
|
||
|
||
if (isLoading)
|
||
return (
|
||
<div className="card max-w-3xl">
|
||
<SettingsNav />
|
||
<div className="muted">Loading…</div>
|
||
</div>
|
||
);
|
||
|
||
if (error || !data) {
|
||
return (
|
||
<div className="card max-w-3xl">
|
||
<SettingsNav />
|
||
<p className="mb-3">Couldn't load fixed expenses.</p>
|
||
<button
|
||
className="btn"
|
||
onClick={() => refetch()}
|
||
disabled={isFetching}
|
||
>
|
||
{isFetching ? "Retrying…" : "Retry"}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const onAdd = (e: FormEvent) => {
|
||
e.preventDefault();
|
||
const totalCents = Math.max(
|
||
0,
|
||
Math.round((parseFloat(total || "0")) * 100),
|
||
);
|
||
const fundedCents = Math.max(
|
||
0,
|
||
Math.round((parseFloat(funded || "0")) * 100),
|
||
);
|
||
const paymentSchedule = autoPayEnabled ? {
|
||
frequency,
|
||
...(frequency === "monthly" ? { dayOfMonth } : {}),
|
||
...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}),
|
||
...(frequency === "custom" ? { everyNDays } : {}),
|
||
minFundingPercent,
|
||
} : undefined;
|
||
|
||
const body = {
|
||
name: name.trim(),
|
||
totalCents,
|
||
fundedCents: Math.min(fundedCents, totalCents),
|
||
priority: Math.max(
|
||
0,
|
||
Math.floor(Number(priority) || 0),
|
||
),
|
||
dueOn: dateStringToUTCMidnight(due, userTimezone),
|
||
autoPayEnabled,
|
||
paymentSchedule,
|
||
};
|
||
if (!body.name || totalCents <= 0) return;
|
||
createM.mutate(body, {
|
||
onSuccess: () => {
|
||
push("ok", "Plan created");
|
||
setName("");
|
||
setTotal("");
|
||
setFunded("");
|
||
setPriority("");
|
||
setDue(getTodayInTimezone(userTimezone));
|
||
setAutoPayEnabled(false);
|
||
setFrequency("monthly");
|
||
setDayOfMonth(1);
|
||
setDayOfWeek(0);
|
||
setEveryNDays(30);
|
||
setMinFundingPercent(100);
|
||
},
|
||
onError: (err: any) =>
|
||
push("err", err?.message ?? "Create failed"),
|
||
});
|
||
};
|
||
|
||
const onEdit = (
|
||
id: string,
|
||
patch: Partial<{
|
||
name: string;
|
||
totalCents: number;
|
||
fundedCents: number;
|
||
priority: number;
|
||
dueOn: string;
|
||
}>,
|
||
) => {
|
||
if (
|
||
"totalCents" in patch &&
|
||
"fundedCents" in patch &&
|
||
(patch.totalCents ?? 0) < (patch.fundedCents ?? 0)
|
||
) {
|
||
patch.fundedCents = patch.totalCents;
|
||
}
|
||
updateM.mutate(
|
||
{ id, body: patch },
|
||
{
|
||
onSuccess: () => push("ok", "Plan updated"),
|
||
onError: (err: any) =>
|
||
push("err", err?.message ?? "Update failed"),
|
||
},
|
||
);
|
||
};
|
||
|
||
const onDelete = (id: string) => {
|
||
deleteM.mutate(id, {
|
||
onSuccess: () => push("ok", "Plan deleted"),
|
||
onError: (err: any) =>
|
||
push("err", err?.message ?? "Delete failed"),
|
||
});
|
||
};
|
||
|
||
const addDisabled =
|
||
!name || !total || createM.isPending;
|
||
|
||
return (
|
||
<div className="grid gap-4 max-w-5xl">
|
||
<section className="card">
|
||
<SettingsNav />
|
||
|
||
{/* Header */}
|
||
<header className="mb-4 space-y-1">
|
||
<h1 className="text-lg font-semibold">
|
||
Fixed expenses
|
||
</h1>
|
||
<p className="text-sm muted">
|
||
Long-term goals and obligations you’re funding over
|
||
time.
|
||
</p>
|
||
</header>
|
||
|
||
{/* KPI strip */}
|
||
<div className="grid gap-2 sm:grid-cols-4 mb-4">
|
||
<KpiCard label="Funded">
|
||
<Money cents={totals.funded} />
|
||
</KpiCard>
|
||
<KpiCard label="Total">
|
||
<Money cents={totals.total} />
|
||
</KpiCard>
|
||
<KpiCard label="Remaining">
|
||
<Money cents={totals.remaining} />
|
||
</KpiCard>
|
||
<KpiCard label="Overall progress">
|
||
<span className="text-xl font-semibold">
|
||
{overallPctFunded}%
|
||
</span>
|
||
</KpiCard>
|
||
</div>
|
||
|
||
{/* Overall progress bar */}
|
||
<div className="mb-4 space-y-1">
|
||
<div className="row text-xs muted">
|
||
<span>All fixed expenses funded</span>
|
||
<span className="ml-auto">
|
||
{overallPctFunded}% of target
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
||
<div
|
||
className="h-full bg-[--color-accent]"
|
||
style={{
|
||
width: `${Math.min(100, overallPctFunded)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Add form */}
|
||
<form
|
||
onSubmit={onAdd}
|
||
className="row gap-2 mb-4 flex-wrap items-end"
|
||
>
|
||
<input
|
||
className="input w-full sm:w-48"
|
||
placeholder="Name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
<input
|
||
className="input w-full sm:w-28"
|
||
placeholder="Total $"
|
||
type="number"
|
||
min={0}
|
||
step="0.01"
|
||
value={total}
|
||
onChange={(e) => setTotal(e.target.value)}
|
||
/>
|
||
<input
|
||
className="input w-full sm:w-28"
|
||
placeholder="Funded $"
|
||
type="number"
|
||
min={0}
|
||
step="0.01"
|
||
value={funded}
|
||
onChange={(e) => setFunded(e.target.value)}
|
||
/>
|
||
<input
|
||
className="input w-full sm:w-24"
|
||
placeholder="Priority"
|
||
type="number"
|
||
min={0}
|
||
value={priority}
|
||
onChange={(e) => setPriority(e.target.value)}
|
||
/>
|
||
<input
|
||
className="input w-full sm:w-40"
|
||
type="date"
|
||
value={due}
|
||
onChange={(e) => setDue(e.target.value)}
|
||
/>
|
||
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
|
||
<input
|
||
type="checkbox"
|
||
checked={autoPayEnabled}
|
||
onChange={(e) => setAutoPayEnabled(e.target.checked)}
|
||
/>
|
||
<span>Auto-fund</span>
|
||
</label>
|
||
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
|
||
Add
|
||
</button>
|
||
</form>
|
||
|
||
{/* Auto-payment configuration */}
|
||
{autoPayEnabled && (
|
||
<div className="card bg-[--color-panel] p-4 mb-4">
|
||
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
|
||
<div className="row gap-4 flex-wrap items-end">
|
||
<label className="stack text-sm">
|
||
<span className="muted text-xs">Frequency</span>
|
||
<select
|
||
className="input w-32"
|
||
value={frequency}
|
||
onChange={(e) => setFrequency(e.target.value as any)}
|
||
>
|
||
<option value="weekly">Weekly</option>
|
||
<option value="biweekly">Biweekly</option>
|
||
<option value="monthly">Monthly</option>
|
||
<option value="daily">Daily</option>
|
||
<option value="custom">Custom</option>
|
||
</select>
|
||
</label>
|
||
|
||
{(frequency === "weekly" || frequency === "biweekly") && (
|
||
<label className="stack text-sm">
|
||
<span className="muted text-xs">Day of Week</span>
|
||
<select
|
||
className="input w-32"
|
||
value={dayOfWeek}
|
||
onChange={(e) => setDayOfWeek(Number(e.target.value))}
|
||
>
|
||
<option value={0}>Sunday</option>
|
||
<option value={1}>Monday</option>
|
||
<option value={2}>Tuesday</option>
|
||
<option value={3}>Wednesday</option>
|
||
<option value={4}>Thursday</option>
|
||
<option value={5}>Friday</option>
|
||
<option value={6}>Saturday</option>
|
||
</select>
|
||
</label>
|
||
)}
|
||
|
||
{frequency === "monthly" && (
|
||
<label className="stack text-sm">
|
||
<span className="muted text-xs">Day of Month</span>
|
||
<input
|
||
className="input w-24"
|
||
type="number"
|
||
min="1"
|
||
max="31"
|
||
value={dayOfMonth}
|
||
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
{frequency === "custom" && (
|
||
<label className="stack text-sm">
|
||
<span className="muted text-xs">Every N Days</span>
|
||
<input
|
||
className="input w-24"
|
||
type="number"
|
||
min="1"
|
||
value={everyNDays}
|
||
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
<label className="stack text-sm">
|
||
<span className="muted text-xs">Min. Funding %</span>
|
||
<input
|
||
className="input w-24"
|
||
type="number"
|
||
min="0"
|
||
max="100"
|
||
value={minFundingPercent}
|
||
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<p className="text-xs muted mt-2">
|
||
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Table */}
|
||
{data.fixedPlans.length === 0 ? (
|
||
<div className="muted text-sm">
|
||
No fixed expenses yet.
|
||
</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Due</th>
|
||
<th>Priority</th>
|
||
<th>Funded</th>
|
||
<th>Total</th>
|
||
<th>Remaining</th>
|
||
<th>Status</th>
|
||
<th>Auto-Pay</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.fixedPlans
|
||
.slice()
|
||
.sort(
|
||
(a, b) =>
|
||
a.priority - b.priority ||
|
||
new Date(a.dueOn).getTime() -
|
||
new Date(b.dueOn).getTime(),
|
||
)
|
||
.map((p) => {
|
||
const remaining = Math.max(
|
||
0,
|
||
p.totalCents - p.fundedCents,
|
||
);
|
||
const pctFunded = p.totalCents
|
||
? Math.round(
|
||
(p.fundedCents / p.totalCents) * 100,
|
||
)
|
||
: 0;
|
||
return (
|
||
<tr key={p.id}>
|
||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
||
<InlineEditText
|
||
value={p.name}
|
||
onChange={(v) =>
|
||
onEdit(p.id, { name: v })
|
||
}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<InlineEditDate
|
||
value={p.dueOn}
|
||
timezone={userTimezone}
|
||
onChange={(iso) =>
|
||
onEdit(p.id, { dueOn: iso })
|
||
}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<InlineEditNumber
|
||
value={p.priority}
|
||
min={0}
|
||
onChange={(n) =>
|
||
onEdit(p.id, { priority: n })
|
||
}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<div className="space-y-1">
|
||
<InlineEditMoney
|
||
valueCents={p.fundedCents}
|
||
onChange={(cents) =>
|
||
onEdit(p.id, {
|
||
fundedCents: Math.max(
|
||
0,
|
||
Math.min(cents, p.totalCents),
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
<div className="row text-xs muted">
|
||
<span>{pctFunded}% funded</span>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<div className="space-y-1">
|
||
<InlineEditMoney
|
||
valueCents={p.totalCents}
|
||
onChange={(cents) =>
|
||
onEdit(p.id, {
|
||
totalCents: Math.max(cents, 0),
|
||
fundedCents: Math.min(
|
||
p.fundedCents,
|
||
cents,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
<FundingBar
|
||
pct={pctFunded}
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<Money cents={remaining} />
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<div className="flex items-center space-x-2">
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
p.autoPayEnabled
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-gray-100 text-gray-600'
|
||
}`}>
|
||
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
{p.autoPayEnabled && p.paymentSchedule && (
|
||
<span className="text-xs text-gray-500">
|
||
{p.paymentSchedule.frequency === 'custom'
|
||
? `Every ${p.paymentSchedule.customDays} days`
|
||
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
|
||
}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||
<button
|
||
className="btn"
|
||
type="button"
|
||
onClick={() => onDelete(p.id)}
|
||
disabled={deleteM.isPending}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* --- Small presentational helpers --- */
|
||
|
||
function KpiCard({
|
||
label,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="card kpi">
|
||
<h3>{label}</h3>
|
||
<div className="val">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FundingBar({ pct }: { pct: number }) {
|
||
const clamped = Math.min(100, Math.max(0, pct));
|
||
return (
|
||
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
||
<div
|
||
className="h-full bg-[--color-accent]"
|
||
style={{ width: `${clamped}%` }}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* --- Inline editors (same behavior, slightly nicer UX) --- */
|
||
|
||
function InlineEditText({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [v, setV] = useState(value);
|
||
useEffect(() => setV(value), [value]);
|
||
const commit = () => {
|
||
const t = v.trim();
|
||
if (t && t !== value) onChange(t);
|
||
setEditing(false);
|
||
};
|
||
return editing ? (
|
||
<input
|
||
className="input"
|
||
value={v}
|
||
onChange={(e) => setV(e.target.value)}
|
||
onBlur={commit}
|
||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="link inline-flex items-center gap-1"
|
||
onClick={() => setEditing(true)}
|
||
>
|
||
<span>{value}</span>
|
||
<span className="text-[10px] opacity-60">Edit</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function InlineEditNumber({
|
||
value,
|
||
onChange,
|
||
min = 0,
|
||
max = Number.MAX_SAFE_INTEGER,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
min?: number;
|
||
max?: number;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [v, setV] = useState(String(value));
|
||
useEffect(() => setV(String(value)), [value]);
|
||
const commit = () => {
|
||
const n = Math.max(
|
||
min,
|
||
Math.min(max, Math.floor(Number(v) || 0)),
|
||
);
|
||
if (n !== value) onChange(n);
|
||
setEditing(false);
|
||
};
|
||
return editing ? (
|
||
<input
|
||
className="input w-24 text-right"
|
||
value={v}
|
||
onChange={(e) => setV(e.target.value)}
|
||
onBlur={commit}
|
||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="link"
|
||
onClick={() => setEditing(true)}
|
||
>
|
||
{value}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function InlineEditMoney({
|
||
valueCents,
|
||
onChange,
|
||
}: {
|
||
valueCents: number;
|
||
onChange: (cents: number) => void;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [v, setV] = useState((valueCents / 100).toFixed(2));
|
||
useEffect(
|
||
() => setV((valueCents / 100).toFixed(2)),
|
||
[valueCents],
|
||
);
|
||
const commit = () => {
|
||
const cents = Math.max(
|
||
0,
|
||
Math.round((parseFloat(v || "0")) * 100),
|
||
);
|
||
if (cents !== valueCents) onChange(cents);
|
||
setEditing(false);
|
||
};
|
||
return editing ? (
|
||
<input
|
||
className="input w-28 text-right font-mono"
|
||
type="number"
|
||
step="0.01"
|
||
min={0}
|
||
value={v}
|
||
onChange={(e) => setV(e.target.value)}
|
||
onBlur={commit}
|
||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="link font-mono"
|
||
onClick={() => setEditing(true)}
|
||
>
|
||
{(valueCents / 100).toFixed(2)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function InlineEditDate({
|
||
value,
|
||
onChange,
|
||
timezone,
|
||
}: {
|
||
value: string;
|
||
onChange: (iso: string) => void;
|
||
timezone: string;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [v, setV] = useState(
|
||
isoToDateString(value, timezone),
|
||
);
|
||
useEffect(
|
||
() =>
|
||
setV(
|
||
isoToDateString(value, timezone),
|
||
),
|
||
[value, timezone],
|
||
);
|
||
const commit = () => {
|
||
const iso = dateStringToUTCMidnight(v, timezone);
|
||
if (iso !== value) onChange(iso);
|
||
setEditing(false);
|
||
};
|
||
return editing ? (
|
||
<input
|
||
className="input w-40"
|
||
type="date"
|
||
value={v}
|
||
onChange={(e) => setV(e.target.value)}
|
||
onBlur={commit}
|
||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="link"
|
||
onClick={() => setEditing(true)}
|
||
>
|
||
{formatDateInTimezone(value, timezone)}
|
||
</button>
|
||
);
|
||
}
|