refactor(bill-modal): extract DebtDetailsSection (BM2, 1/n)
First BillModal decompose step: the collapsible Debt/Snowball fields (interest rate, current balance, minimum payment, snowball visibility) move to client/components/bill-modal/DebtDetailsSection.jsx as a presentational component; state stays in the parent (the save action reads it). Behavior- preserving — BillModal 1723 -> 1637 lines; build + client tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2b710c459b
commit
1018c55bb3
|
|
@ -1,5 +1,5 @@
|
||||||
import { useActionState, useEffect, useState } from 'react';
|
import { useActionState, useEffect, useState } from 'react';
|
||||||
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
import { Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money';
|
import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||||
import BillMerchantRules from '@/components/BillMerchantRules';
|
import BillMerchantRules from '@/components/BillMerchantRules';
|
||||||
|
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
|
||||||
import {
|
import {
|
||||||
BILLING_SCHEDULE_OPTIONS,
|
BILLING_SCHEDULE_OPTIONS,
|
||||||
billingCycleForSchedule,
|
billingCycleForSchedule,
|
||||||
|
|
@ -802,113 +803,26 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debt / Snowball Details — collapsible */}
|
{/* Debt / Snowball Details — collapsible */}
|
||||||
<div className="col-span-2">
|
<DebtDetailsSection
|
||||||
<div className="flex items-center gap-3">
|
inp={inp}
|
||||||
<div className="h-px flex-1 bg-border/40" />
|
errors={errors}
|
||||||
<button
|
setErrors={setErrors}
|
||||||
type="button"
|
showDebtSection={showDebtSection}
|
||||||
onClick={() => setShowDebtSection(s => !s)}
|
setShowDebtSection={setShowDebtSection}
|
||||||
className="flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
isSnowballCategory={isSnowballCategory}
|
||||||
>
|
showOnSnowball={showOnSnowball}
|
||||||
<ChevronDown
|
interestRate={interestRate}
|
||||||
className={cn('h-3 w-3 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
setInterestRate={setInterestRate}
|
||||||
/>
|
currentBalance={currentBalance}
|
||||||
Debt / Snowball Details
|
setCurrentBalance={setCurrentBalance}
|
||||||
{isSnowballCategory && (
|
minimumPayment={minimumPayment}
|
||||||
<span className="text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
setMinimumPayment={setMinimumPayment}
|
||||||
· auto-detected
|
validateInterestRate={validateInterestRate}
|
||||||
</span>
|
validateCurrentBalance={validateCurrentBalance}
|
||||||
)}
|
validateMinimumPayment={validateMinimumPayment}
|
||||||
{!showOnSnowball && isSnowballCategory && (
|
handleBlur={handleBlur}
|
||||||
<span className="text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
onSnowballVisibilityChange={handleSnowballVisibilityChange}
|
||||||
· exempt
|
/>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="h-px flex-1 bg-border/40" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDebtSection && (
|
|
||||||
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 p-3 rounded-xl bg-muted/20 border border-border/30">
|
|
||||||
|
|
||||||
{/* Interest Rate */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
|
|
||||||
<Input
|
|
||||||
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
|
|
||||||
type="number" min="0" max="100" step="0.01" placeholder="Optional"
|
|
||||||
value={interestRate}
|
|
||||||
onChange={e => {
|
|
||||||
setInterestRate(e.target.value);
|
|
||||||
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
|
|
||||||
}}
|
|
||||||
onBlur={() => handleBlur('interestRate', interestRate, validateInterestRate)}
|
|
||||||
/>
|
|
||||||
{errors.interestRate && (
|
|
||||||
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground/70">Enter 29.99 for 29.99%.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Balance */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Current Balance ($)</Label>
|
|
||||||
<Input
|
|
||||||
className={cn(inp, 'font-mono', errors.currentBalance && 'border-red-500 focus-visible:ring-red-500')}
|
|
||||||
type="number" min="0" step="0.01" placeholder="Optional"
|
|
||||||
value={currentBalance}
|
|
||||||
onChange={e => {
|
|
||||||
setCurrentBalance(e.target.value);
|
|
||||||
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
|
|
||||||
}}
|
|
||||||
onBlur={() => handleBlur('currentBalance', currentBalance, validateCurrentBalance)}
|
|
||||||
/>
|
|
||||||
{errors.currentBalance && (
|
|
||||||
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground/70">Outstanding debt balance.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Minimum Payment */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Minimum Payment ($)</Label>
|
|
||||||
<Input
|
|
||||||
className={cn(inp, 'font-mono', errors.minimumPayment && 'border-red-500 focus-visible:ring-red-500')}
|
|
||||||
type="number" min="0" step="0.01" placeholder="Optional"
|
|
||||||
value={minimumPayment}
|
|
||||||
onChange={e => {
|
|
||||||
setMinimumPayment(e.target.value);
|
|
||||||
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
|
|
||||||
}}
|
|
||||||
onBlur={() => handleBlur('minimumPayment', minimumPayment, validateMinimumPayment)}
|
|
||||||
/>
|
|
||||||
{errors.minimumPayment && (
|
|
||||||
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground/70">Required minimum monthly payment.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Include in Snowball */}
|
|
||||||
<div className="flex flex-col justify-end pb-1 space-y-1">
|
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showOnSnowball}
|
|
||||||
onChange={e => handleSnowballVisibilityChange(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-border accent-emerald-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
|
||||||
Show on Debt Snowball
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-[10px] text-muted-foreground/70 pl-6">
|
|
||||||
Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Website */}
|
{/* Website */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
// Collapsible Debt / Snowball fields (interest rate, current balance, minimum
|
||||||
|
// payment, snowball visibility). State lives in the parent BillModal (the save
|
||||||
|
// action reads these values); this is a presentational extraction.
|
||||||
|
export default function DebtDetailsSection({
|
||||||
|
inp,
|
||||||
|
errors, setErrors,
|
||||||
|
showDebtSection, setShowDebtSection,
|
||||||
|
isSnowballCategory, showOnSnowball,
|
||||||
|
interestRate, setInterestRate,
|
||||||
|
currentBalance, setCurrentBalance,
|
||||||
|
minimumPayment, setMinimumPayment,
|
||||||
|
validateInterestRate, validateCurrentBalance, validateMinimumPayment,
|
||||||
|
handleBlur,
|
||||||
|
onSnowballVisibilityChange,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDebtSection(s => !s)}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn('h-3 w-3 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
||||||
|
/>
|
||||||
|
Debt / Snowball Details
|
||||||
|
{isSnowballCategory && (
|
||||||
|
<span className="text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
||||||
|
· auto-detected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!showOnSnowball && isSnowballCategory && (
|
||||||
|
<span className="text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
||||||
|
· exempt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDebtSection && (
|
||||||
|
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 p-3 rounded-xl bg-muted/20 border border-border/30">
|
||||||
|
|
||||||
|
{/* Interest Rate */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
|
||||||
|
<Input
|
||||||
|
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
|
||||||
|
type="number" min="0" max="100" step="0.01" placeholder="Optional"
|
||||||
|
value={interestRate}
|
||||||
|
onChange={e => {
|
||||||
|
setInterestRate(e.target.value);
|
||||||
|
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleBlur('interestRate', interestRate, validateInterestRate)}
|
||||||
|
/>
|
||||||
|
{errors.interestRate && (
|
||||||
|
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">Enter 29.99 for 29.99%.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Balance */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Current Balance ($)</Label>
|
||||||
|
<Input
|
||||||
|
className={cn(inp, 'font-mono', errors.currentBalance && 'border-red-500 focus-visible:ring-red-500')}
|
||||||
|
type="number" min="0" step="0.01" placeholder="Optional"
|
||||||
|
value={currentBalance}
|
||||||
|
onChange={e => {
|
||||||
|
setCurrentBalance(e.target.value);
|
||||||
|
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleBlur('currentBalance', currentBalance, validateCurrentBalance)}
|
||||||
|
/>
|
||||||
|
{errors.currentBalance && (
|
||||||
|
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">Outstanding debt balance.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Minimum Payment */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Minimum Payment ($)</Label>
|
||||||
|
<Input
|
||||||
|
className={cn(inp, 'font-mono', errors.minimumPayment && 'border-red-500 focus-visible:ring-red-500')}
|
||||||
|
type="number" min="0" step="0.01" placeholder="Optional"
|
||||||
|
value={minimumPayment}
|
||||||
|
onChange={e => {
|
||||||
|
setMinimumPayment(e.target.value);
|
||||||
|
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleBlur('minimumPayment', minimumPayment, validateMinimumPayment)}
|
||||||
|
/>
|
||||||
|
{errors.minimumPayment && (
|
||||||
|
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">Required minimum monthly payment.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Include in Snowball */}
|
||||||
|
<div className="flex flex-col justify-end pb-1 space-y-1">
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showOnSnowball}
|
||||||
|
onChange={e => onSnowballVisibilityChange(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-border accent-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
Show on Debt Snowball
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 pl-6">
|
||||||
|
Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue