BillTracker/client/components/bill-modal/DebtDetailsSection.tsx

217 lines
7.7 KiB
TypeScript

import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import type { Dispatch, SetStateAction } from 'react';
type FormErrors = Record<string, string>;
type Validator = (v: string) => string;
interface DebtDetailsSectionProps {
inp?: string;
errors: FormErrors;
setErrors: Dispatch<SetStateAction<FormErrors>>;
showDebtSection: boolean;
setShowDebtSection: Dispatch<SetStateAction<boolean>>;
isSnowballCategory?: boolean;
showOnSnowball?: boolean;
interestRate: string;
setInterestRate: (v: string) => void;
currentBalance: string;
setCurrentBalance: (v: string) => void;
minimumPayment: string;
setMinimumPayment: (v: string) => void;
validateInterestRate: Validator;
validateCurrentBalance: Validator;
validateMinimumPayment: Validator;
handleBlur: (field: string, value: string, validator: Validator) => void;
onSnowballVisibilityChange: (checked: boolean) => void;
}
// 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,
}: DebtDetailsSectionProps) {
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>
);
}