diff --git a/frontend/src/components/PropertyDetail/PropertyDetail.tsx b/frontend/src/components/PropertyDetail/PropertyDetail.tsx index 33369f4..d7a4b98 100644 --- a/frontend/src/components/PropertyDetail/PropertyDetail.tsx +++ b/frontend/src/components/PropertyDetail/PropertyDetail.tsx @@ -277,6 +277,7 @@ export function PropertyDetail({
Loading assumptions…
) : assumptions ? ( (12).fill(false); + if (peakMonths <= 0) return mask; + if (peakMonths >= 12) return mask.map(() => true); + const start = Math.max(0, Math.min(12 - peakMonths, 7 - Math.floor(peakMonths / 2))); + for (let i = start; i < start + peakMonths; i++) mask[i] = true; + return mask; +} interface RevenueExpensesTabProps { + propertyId: string; assumptions: STRAssumptions; onUpdate: (updates: Partial) => Promise; ltrAssumptions: LTRAssumptions | null; onUpdateLTR: (updates: Partial) => Promise; - activeRentalType: 'str' | 'ltr'; - onChangeRentalType: (type: 'str' | 'ltr') => void; + activeRentalType: "str" | "ltr"; + onChangeRentalType: (type: "str" | "ltr") => void; } -const TOOLTIPS = { - avg_nightly_rate: - "Your expected average nightly rate across all seasons. Research comparable STR listings in the area on Airbnb/VRBO. Look at similar bed count, amenities, and location.", - occupancy_pct: - "Percentage of nights booked per year. 65% is a solid benchmark for established STR markets. New listings typically start at 40-50% and ramp up over 6-12 months. Top performers hit 75-85%.", - cleaning_fee_per_stay: - "Fee charged to the guest per booking. US averages by bedroom count: 1BR ~$100, 2BR ~$155, 3BR ~$175, 4BR+ ~$200-$300+. Set competitively relative to your market.", - avg_stay_length_nights: - "Average number of nights per booking. Urban STRs average 2-3 nights. Vacation/resort areas: 4-7 nights. Longer stays = fewer turnovers = lower cleaning costs.", - platform_fee_pct: - "Airbnb's split-fee model charges hosts 3% per booking. VRBO charges 5-8%. If using Airbnb's host-only model (required if using channel managers), the fee is 14-15%.", - cleaning_cost_per_turn: - "What you pay your cleaner each turnover. Typical ranges: 1BR $50-$90, 2BR $70-$130, 3BR $100-$150, 4BR+ $150-$300. Rule of thumb: $50/bathroom + $35/bedroom.", - property_mgmt_pct: - "Fee paid to a property manager as a % of net revenue. 0% if self-managing. Full-service management typically charges 20-25%. Co-hosting or partial management: 10-15%.", - utilities_monthly: - "Total monthly utilities: electric, gas, water, sewer, internet, trash, streaming services. STRs run higher utility costs than typical homes. Budget $150-$250 for a 1-2BR, $250-$400 for a 3-4BR.", - insurance_annual: - "Total annual STR insurance policy cost. Standard homeowner's insurance does NOT cover STR use. You need a specialized STR policy. Typical range: $1,500-$3,000/yr.", - maintenance_reserve_pct: - "Percentage of gross revenue set aside for routine maintenance (plumbing, HVAC repairs, appliance fixes). Industry standard is 5%. Alternative: 1% of property value per year.", - capex_reserve_pct: - "Percentage of gross revenue reserved for major capital expenditures (roof, HVAC system, water heater, appliances). 5% is standard. Some investors use 10% total (combined maintenance + capex).", - supplies_monthly: - "Consumables replaced regularly: toiletries, paper products, coffee, cleaning supplies, light bulbs, batteries, small linens. Budget $50-$100 for a 1-2BR, $100-$200 for a 3-4BR.", - lawn_snow_monthly: - "Landscaping, mowing, leaf cleanup, snow removal. Highly seasonal in Vermont. Enter a monthly average across the year, e.g., $100-$150. Set to $0 for condos.", - other_monthly_expense: - "Catch-all for anything not covered above: pest control, pool/hot tub maintenance, security monitoring, etc.", - marketing_monthly: - "Photography, listing optimization, social media, direct booking website. Initial setup $200-$500, ongoing $50-$200/mo. Set to $0 if relying solely on platform traffic.", - software_monthly: - "PMS (Hospitable, Guesty), dynamic pricing (PriceLabs, Beyond), channel manager, smart lock software. Typical total: $30-$80/mo.", - accounting_annual: - "Tax preparation, bookkeeping, financial reporting. STR taxes are more complex than W-2. Typical range: $1,000-$3,000/yr for a CPA familiar with rental properties.", - legal_annual: - "Legal counsel, compliance reviews, lease/contract templates, permit applications. Typical: $500-$1,500/yr. Higher in heavily regulated markets.", - state_rooms_tax_pct: - "Vermont Meals & Rooms Tax applied to all STR bookings of < 30 nights. Collected from the guest. If you use Airbnb or VRBO, the platform collects and remits this for you automatically.", - str_surcharge_pct: - "Additional surcharge on STR rents enacted by Act 183 of 2024. Collected from the guest on top of the 9% rooms tax. Also remitted by major platforms.", - local_option_tax_pct: - "Additional municipal tax adopted by some Vermont towns. Currently 1% where adopted (Burlington, Stowe, Killington, and others). Set to 0% if your town hasn't adopted it.", - local_str_registration_fee: - "Annual municipal registration fee. Not all towns require it. Examples: Stowe $100/unit/yr, Dover $125/yr. Check your specific town's requirements.", - platform_remits_tax: - "If Yes (Airbnb, VRBO), the platform collects all VT rooms/meals taxes from guests and remits to the state on your behalf. These taxes are pass-through, not your expense.", - rental_delay_months: - "Months between property acquisition and first guest booking. Covers renovation, furnishing, permits, and listing setup. During this period you pay all carrying costs with zero revenue. Default: 1 month.", - local_gross_receipts_tax_pct: - "Burlington, VT levies a 9% gross receipts tax on STR revenue. Unlike rooms tax, this is an operator expense that directly reduces your cashflow. Set to 0% outside Burlington.", - damage_reserve_pct: - "Percentage of gross revenue set aside for guest damage repairs (broken furniture, stains, wall holes). 2% is typical. Airbnb's Host Guarantee covers some damage but not everything.", - land_value_pct: - "Percentage of the purchase price attributable to land (non-depreciable). Typical range: 15-30%. Check your county tax assessment for the land/building split. Used for depreciation calculations.", - property_appreciation_pct_annual: - "Expected annual property value appreciation. US historical average is ~3-4%. Conservative: 2%. Set to 0% to exclude appreciation from ROI calculations.", - use_seasonal_occupancy: - "Enable peak/off-peak occupancy modeling. When on, occupancy varies by season. A weighted effective occupancy is computed for all annual metrics.", - peak_months: - "Number of peak-season months per year. For Vermont: June-November (summer + foliage) = 6 months. Adjust based on your market's high season.", - peak_occupancy_pct: - "Expected occupancy during peak months. Top STR markets see 75-90% during peak season.", - off_peak_occupancy_pct: - "Expected occupancy during off-peak months. Winter/shoulder months in non-ski markets typically see 30-50%.", - marginal_tax_rate_pct: - "Your combined marginal income tax rate (federal + state). Used to estimate after-tax cashflow. Common ranges: 22-37% federal + 0-13% state. Set to 0 to hide tax analysis.", - revenue_growth_pct: - "Expected annual revenue growth rate for multi-year projections. Accounts for nightly rate increases, market growth, and improved occupancy over time. US STR average: 3-5%.", - expense_growth_pct: - "Expected annual expense growth rate for multi-year projections. Covers inflation on operating costs (cleaning, utilities, supplies, maintenance). General inflation: 2-4%.", - hold_period_years: - "Number of years you plan to hold the property before selling. Used for exit analysis and IRR with exit modeling. Typical hold periods: 5-10 years.", - selling_cost_pct: - "Total selling costs as a percentage of sale price. Includes broker commissions (5-6%), transfer taxes, and closing costs. Typical total: 7-10%.", - capital_gains_rate_pct: - "Combined federal and state capital gains tax rate on the profit from selling the property. Federal long-term rate: 15-20%. Add state tax if applicable.", - depreciation_recapture_rate_pct: - "Tax rate on accumulated depreciation when selling. Federal depreciation recapture is taxed at 25%. This applies to the total depreciation claimed during the hold period.", -}; +interface PreviewValues { + revenue: number; + expenses: number; + cashflow: number; +} const SHARED_KEYS = [ - 'insurance_annual', 'maintenance_reserve_pct', 'capex_reserve_pct', - 'lawn_snow_monthly', 'other_monthly_expense', 'accounting_annual', - 'legal_annual', 'land_value_pct', 'property_appreciation_pct_annual', - 'revenue_growth_pct', 'expense_growth_pct', 'marginal_tax_rate_pct', - 'hold_period_years', 'selling_cost_pct', 'capital_gains_rate_pct', - 'depreciation_recapture_rate_pct', + "insurance_annual", + "maintenance_reserve_pct", + "capex_reserve_pct", + "lawn_snow_monthly", + "other_monthly_expense", + "accounting_annual", + "legal_annual", + "land_value_pct", + "property_appreciation_pct_annual", + "revenue_growth_pct", + "expense_growth_pct", + "marginal_tax_rate_pct", + "hold_period_years", + "selling_cost_pct", + "capital_gains_rate_pct", + "depreciation_recapture_rate_pct", +] as const; + +const RENTAL_OPTIONS = [ + { value: "str", label: "STR" }, + { value: "ltr", label: "LTR" }, ] as const; -export function RevenueExpensesTab({ assumptions, onUpdate, ltrAssumptions, onUpdateLTR, activeRentalType, onChangeRentalType }: RevenueExpensesTabProps) { +const MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function formatCurrency(value: number): string { + const rounded = Math.round(value); + const abs = Math.abs(rounded); + return `${rounded < 0 ? "-" : ""}$${abs.toLocaleString("en-US")}`; +} + +export function RevenueExpensesTab({ + propertyId, + assumptions, + onUpdate, + ltrAssumptions, + onUpdateLTR, + activeRentalType, + onChangeRentalType, +}: RevenueExpensesTabProps) { const [form, setForm] = useState({ ...assumptions }); - const [ltrForm, setLtrForm] = useState(ltrAssumptions ? { ...ltrAssumptions } : null); + const [ltrForm, setLtrForm] = useState( + ltrAssumptions ? { ...ltrAssumptions } : null + ); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); + const [preview, setPreview] = useState(null); - const updateField = useCallback((key: K, value: STRAssumptions[K]) => { - setForm((prev) => ({ ...prev, [key]: value })); - setSaved(false); - }, []); + const updateField = useCallback( + (key: K, value: STRAssumptions[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + setSaved(false); + }, + [] + ); - const updateLTRField = useCallback((key: K, value: LTRAssumptions[K]) => { - setLtrForm((prev) => prev ? ({ ...prev, [key]: value }) : prev); - setSaved(false); - }, []); + const updateLTRField = useCallback( + (key: K, value: LTRAssumptions[K]) => { + setLtrForm((prev) => (prev ? { ...prev, [key]: value } : prev)); + setSaved(false); + }, + [] + ); - // Update a shared field in both form states - const updateSharedField = useCallback((key: K, value: number) => { - setForm((prev) => ({ ...prev, [key]: value })); - setLtrForm((prev) => prev ? ({ ...prev, [key]: value }) : prev); - setSaved(false); - }, []); + const updateSharedField = useCallback( + (key: K, value: number) => { + setForm((prev) => ({ ...prev, [key]: value })); + setLtrForm((prev) => (prev ? { ...prev, [key]: value } : prev)); + setSaved(false); + }, + [] + ); + + const loadPreview = useCallback(async () => { + try { + if (activeRentalType === "ltr") { + const res = await getLTRResults(propertyId); + setPreview({ + revenue: res.revenue.effective_annual / 12, + expenses: res.expenses.total_annual_operating / 12, + cashflow: res.metrics.monthly_cashflow, + }); + } else { + const res = await getResults(propertyId); + setPreview({ + revenue: res.revenue.net_annual / 12, + expenses: res.expenses.total_annual_operating / 12, + cashflow: res.metrics.monthly_cashflow, + }); + } + } catch { + setPreview(null); + } + }, [propertyId, activeRentalType]); + + useEffect(() => { + void loadPreview(); + }, [loadPreview]); const handleSave = useCallback(async () => { try { setSaving(true); - // Build shared field values from the STR form (source of truth for shared fields) const sharedValues: Record = {}; for (const key of SHARED_KEYS) { sharedValues[key] = form[key] as number; } - - // Save active type first (triggers cache recompute), then sync shared fields to the other - if (activeRentalType === 'ltr' && ltrForm) { + if (activeRentalType === "ltr" && ltrForm) { await onUpdateLTR({ ...ltrForm, ...sharedValues }); await onUpdate(sharedValues as Partial); } else { @@ -148,417 +174,797 @@ export function RevenueExpensesTab({ assumptions, onUpdate, ltrAssumptions, onUp } setSaved(true); setTimeout(() => setSaved(false), 2000); - } catch { - // error handled by parent + void loadPreview(); } finally { setSaving(false); } - }, [form, ltrForm, onUpdate, onUpdateLTR, activeRentalType]); + }, [form, ltrForm, onUpdate, onUpdateLTR, activeRentalType, loadPreview]); return ( -
- {/* Rental Type Toggle */} -
- Rental Type: - - -
+
+ {/* Main column */} +
+
+ Rental type + onChangeRentalType(v as "str" | "ltr")} + /> +
- {activeRentalType === 'ltr' && ltrForm ? ( - - ) : ( -
- {/* Left column: Revenue */} -
-

Revenue

- updateField("avg_nightly_rate", v)} - tooltip={TOOLTIPS.avg_nightly_rate} + {activeRentalType === "str" ? ( + - {/* Occupancy: flat or monthly profile */} -
-
- { - if (e.target.checked) { - const defaultProfile: MonthlyProfileEntry[] = Array.from({ length: 12 }, (_, i) => ({ - month: i + 1, - nightly_rate: form.avg_nightly_rate, - occupancy_pct: form.occupancy_pct, - })); - updateField("monthly_revenue_profile", defaultProfile); - updateField("profile_template_name", "Custom"); - } else { - updateField("monthly_revenue_profile", null); - updateField("profile_template_name", null); - } - }} - className="h-4 w-4 text-indigo-600 rounded border-slate-300 dark:border-slate-600" - /> - - Use Monthly Revenue Profile - - -
- {form.monthly_revenue_profile !== null ? ( - updateField("monthly_revenue_profile", profile)} - onTemplateChange={(name) => updateField("profile_template_name", name)} - /> - ) : ( - updateField("occupancy_pct", v)} - tooltip={TOOLTIPS.occupancy_pct} - /> - )} -
- updateField("cleaning_fee_per_stay", v)} - tooltip={TOOLTIPS.cleaning_fee_per_stay} + ) : ltrForm ? ( + -
- - updateField("avg_stay_length_nights", parseFloat(e.target.value) || 0)} - className="w-full px-3 py-2 border border-slate-200 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:text-slate-100" - /> + ) : ( +
+ LTR assumptions unavailable. Save STR assumptions first to + initialize.
-
- - updateField("rental_delay_months", parseInt(e.target.value) || 0)} - className="w-full px-3 py-2 border border-slate-200 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:text-slate-100" - /> -
-
+ )} - {/* Right column: Expenses */} -
-

Expenses

+ {/* Save */} +
+ + {saved && Saved} +
+
- {/* Platform & Management */} -
-

Platform & Management

+ {/* Sticky preview aside */} +
+ +
+ ); +} - {/* Turnover Costs */} -
-

Turnover Costs

- updateField("cleaning_cost_per_turn", v)} - tooltip={TOOLTIPS.cleaning_cost_per_turn} - /> -
+function PreviewRow({ + label, + value, + emphasis = false, + tone, +}: { + label: string; + value: number; + emphasis?: boolean; + tone?: "positive" | "negative"; +}) { + const toneClass = + tone === "positive" + ? "text-accent" + : tone === "negative" + ? "text-negative" + : "text-ink"; + return ( +
+ {label} + + {formatCurrency(value)} + +
+ ); +} - {/* Monthly Fixed */} -
-

Monthly Fixed

-
- updateField("utilities_monthly", v)} - tooltip={TOOLTIPS.utilities_monthly} - /> - updateField("supplies_monthly", v)} - tooltip={TOOLTIPS.supplies_monthly} - /> - updateField("marketing_monthly", v)} - tooltip={TOOLTIPS.marketing_monthly} - /> - updateField("software_monthly", v)} - tooltip={TOOLTIPS.software_monthly} - /> -
-
+// --- STR sections --------------------------------------------------------- - {/* Reserves (STR-specific) */} -
-

Reserves

-
- updateField("damage_reserve_pct", v)} - tooltip={TOOLTIPS.damage_reserve_pct} - /> -
-
-
-
- )} - - {/* Shared Assumptions (always visible) */} -
-

Shared Assumptions

-

These values apply to both STR and LTR calculations and are kept in sync.

-
- {/* Reserves */} -
-

Reserves

- updateSharedField("maintenance_reserve_pct", v)} - tooltip={TOOLTIPS.maintenance_reserve_pct} - /> - updateSharedField("capex_reserve_pct", v)} - tooltip={TOOLTIPS.capex_reserve_pct} - /> -
+function STRSections({ + form, + updateField, + updateSharedField, +}: { + form: STRAssumptions; + updateField: ( + key: K, + value: STRAssumptions[K] + ) => void; + updateSharedField: ( + key: K, + value: number + ) => void; +}) { + const peakMask = derivePeakMask(form.peak_months); + const occValues = Array.from({ length: 12 }, (_, i) => + peakMask[i] ? form.peak_occupancy_pct : form.off_peak_occupancy_pct + ); + const weightedOcc = + (form.peak_months * form.peak_occupancy_pct + + (12 - form.peak_months) * form.off_peak_occupancy_pct) / + 12; - {/* Fixed Costs */} -
-

Fixed Costs

- updateSharedField("insurance_annual", v)} - tooltip={TOOLTIPS.insurance_annual} - /> - updateSharedField("lawn_snow_monthly", v)} - tooltip={TOOLTIPS.lawn_snow_monthly} - /> - updateSharedField("other_monthly_expense", v)} - tooltip={TOOLTIPS.other_monthly_expense} - /> - updateSharedField("accounting_annual", v)} - tooltip={TOOLTIPS.accounting_annual} - /> - updateSharedField("legal_annual", v)} - tooltip={TOOLTIPS.legal_annual} - /> -
+ const useSeasonal = form.use_seasonal_occupancy; + const useMonthlyProfile = form.monthly_revenue_profile !== null; - {/* Growth & Tax */} -
-

Growth & Tax

- updateSharedField("property_appreciation_pct_annual", v)} - tooltip={TOOLTIPS.property_appreciation_pct_annual} - /> - updateSharedField("revenue_growth_pct", v)} - tooltip={TOOLTIPS.revenue_growth_pct} - /> - updateSharedField("expense_growth_pct", v)} - tooltip={TOOLTIPS.expense_growth_pct} - /> - updateSharedField("land_value_pct", v)} - tooltip={TOOLTIPS.land_value_pct} - /> - updateSharedField("marginal_tax_rate_pct", v)} - tooltip={TOOLTIPS.marginal_tax_rate_pct} - /> -
+ return ( + <> + + updateField("avg_nightly_rate", v)} + /> + updateField("cleaning_fee_per_stay", v)} + /> + + updateField( + "avg_stay_length_nights", + parseFloat(e.target.value) || 0 + ) + } + /> + + updateField("rental_delay_months", parseInt(e.target.value) || 0) + } + /> + updateField("platform_fee_pct", v)} + /> + updateField("property_mgmt_pct", v)} + /> + - {/* Exit & IRR */} -
-

Exit & IRR

+ {/* Occupancy panel */} +
+
+

+ Occupancy +

+

+ Pick a flat rate, a peak / off-peak split, or month-level + overrides. +

+
+
+
- - updateSharedField("hold_period_years", parseInt(e.target.value) || 5)} - className="w-full pl-3 py-2 border border-slate-200 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-800 dark:text-slate-100" - /> + +

+ Blend peak + off-peak occupancy through the year. +

- updateSharedField("selling_cost_pct", v)} - tooltip={TOOLTIPS.selling_cost_pct} - /> - updateSharedField("capital_gains_rate_pct", v)} - tooltip={TOOLTIPS.capital_gains_rate_pct} - /> - updateSharedField("depreciation_recapture_rate_pct", v)} - tooltip={TOOLTIPS.depreciation_recapture_rate_pct} + updateField("use_seasonal_occupancy", v)} + label="Use seasonal occupancy" />
+ + {!useSeasonal ? ( + updateField("occupancy_pct", v)} + /> + ) : ( + <> +
+ updateField("peak_months", v)} + /> + undefined} + /> + updateField("peak_occupancy_pct", v)} + /> + updateField("off_peak_occupancy_pct", v)} + /> +
+ +
+ + + Peak + + + + Off-peak + +
+ + )} + +
+ + Advanced · monthly rate & occupancy overrides + +
+
+

+ Overrides the flat or seasonal model with per-month + values. +

+ { + if (v) { + const defaultProfile: MonthlyProfileEntry[] = + Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + nightly_rate: form.avg_nightly_rate, + occupancy_pct: form.occupancy_pct, + })); + updateField("monthly_revenue_profile", defaultProfile); + updateField("profile_template_name", "Custom"); + } else { + updateField("monthly_revenue_profile", null); + updateField("profile_template_name", null); + } + }} + label="Use monthly overrides" + /> +
+ {useMonthlyProfile && form.monthly_revenue_profile && ( + + updateField("monthly_revenue_profile", p) + } + onTemplateChange={(n) => + updateField("profile_template_name", n) + } + /> + )} +
+
- {/* Vermont / State Taxes (STR-only) */} - {activeRentalType === 'str' && ( -
-

Vermont / State Taxes

-
+ + updateField("cleaning_cost_per_turn", v)} + /> + updateField("utilities_monthly", v)} + /> + updateField("supplies_monthly", v)} + /> + updateField("marketing_monthly", v)} + /> + updateField("software_monthly", v)} + /> + updateSharedField("lawn_snow_monthly", v)} + /> + updateSharedField("other_monthly_expense", v)} + /> + updateSharedField("insurance_annual", v)} + /> + + + + updateSharedField("maintenance_reserve_pct", v)} + /> + updateSharedField("capex_reserve_pct", v)} + /> + updateField("damage_reserve_pct", v)} + /> + + + + updateSharedField("accounting_annual", v)} + /> + updateSharedField("legal_annual", v)} + /> + + + {/* VT Tax collapsible (STR only) */} +
+ + Vermont tax configuration + + Rooms tax, surcharge, local options + + +
updateField("state_rooms_tax_pct", v)} - tooltip={TOOLTIPS.state_rooms_tax_pct} /> updateField("str_surcharge_pct", v)} - tooltip={TOOLTIPS.str_surcharge_pct} /> updateField("local_option_tax_pct", v)} - tooltip={TOOLTIPS.local_option_tax_pct} /> updateField("local_gross_receipts_tax_pct", v)} - tooltip={TOOLTIPS.local_gross_receipts_tax_pct} /> updateField("local_str_registration_fee", v)} - tooltip={TOOLTIPS.local_str_registration_fee} /> -
- -
- updateField("platform_remits_tax", e.target.checked)} - className="h-4 w-4 text-indigo-600 rounded border-slate-300 dark:border-slate-600" - /> - - Platform collects and remits taxes - +
+
+ +

+ Airbnb / VRBO collect & remit on your behalf. +

+ updateField("platform_remits_tax", v)} + label="Platform remits tax" + />
-
- )} - - {/* Save */} -
- - {saved && Saved successfully} -
-
+ + + {/* Growth & Exit collapsible (shared fields) */} +
+ + Growth & exit assumptions + + Shared across STR / LTR + + +
+ + updateSharedField("property_appreciation_pct_annual", v) + } + /> + updateSharedField("revenue_growth_pct", v)} + /> + updateSharedField("expense_growth_pct", v)} + /> + updateSharedField("land_value_pct", v)} + /> + updateSharedField("marginal_tax_rate_pct", v)} + /> + + updateSharedField( + "hold_period_years", + parseInt(e.target.value) || 5 + ) + } + /> + updateSharedField("selling_cost_pct", v)} + /> + updateSharedField("capital_gains_rate_pct", v)} + /> + + updateSharedField("depreciation_recapture_rate_pct", v) + } + /> +
+
+ ); } -const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +// --- LTR sections --------------------------------------------------------- + +function LTRSections({ + form, + updateLTRField, + strForm, + updateSharedField, +}: { + form: LTRAssumptions; + updateLTRField: ( + key: K, + value: LTRAssumptions[K] + ) => void; + strForm: STRAssumptions; + updateSharedField: ( + key: K, + value: number + ) => void; +}) { + return ( + <> + + updateLTRField("monthly_rent", v)} + /> + + updateLTRField( + "lease_duration_months", + parseInt(e.target.value) || 12 + ) + } + /> + updateLTRField("pet_rent_monthly", v)} + /> + updateLTRField("late_fee_monthly", v)} + /> + updateLTRField("vacancy_rate_pct", v)} + /> + + updateLTRField( + "lease_up_period_months", + parseInt(e.target.value) || 0 + ) + } + /> + + + + updateLTRField("property_mgmt_pct", v)} + /> + updateLTRField("tenant_turnover_cost", v)} + /> + updateLTRField("utilities_monthly", v)} + /> + updateLTRField("landlord_repairs_annual", v)} + /> + updateSharedField("insurance_annual", v)} + /> + updateSharedField("lawn_snow_monthly", v)} + /> + updateSharedField("other_monthly_expense", v)} + /> + updateSharedField("accounting_annual", v)} + /> + + + + updateSharedField("maintenance_reserve_pct", v)} + /> + updateSharedField("capex_reserve_pct", v)} + /> + + + + updateSharedField("legal_annual", v)} + /> + + +
+ + Growth & exit assumptions + + Shared across STR / LTR + + +
+ + updateSharedField("property_appreciation_pct_annual", v) + } + /> + updateSharedField("revenue_growth_pct", v)} + /> + updateSharedField("expense_growth_pct", v)} + /> + updateSharedField("land_value_pct", v)} + /> + updateSharedField("marginal_tax_rate_pct", v)} + /> + + updateSharedField( + "hold_period_years", + parseInt(e.target.value) || 5 + ) + } + /> + updateSharedField("selling_cost_pct", v)} + /> + updateSharedField("capital_gains_rate_pct", v)} + /> + + updateSharedField("depreciation_recapture_rate_pct", v) + } + /> +
+
+ + ); +} + +// --- Monthly Profile Editor (preserved) ----------------------------------- function MonthlyProfileEditor({ profile, @@ -571,50 +977,57 @@ function MonthlyProfileEditor({ onProfileChange: (profile: MonthlyProfileEntry[]) => void; onTemplateChange: (name: string | null) => void; }) { - const [templates, setTemplates] = useState>({}); + const [templates, setTemplates] = useState< + Record + >({}); useEffect(() => { - getProfileTemplates().then(setTemplates).catch(() => {}); + getProfileTemplates() + .then(setTemplates) + .catch(() => undefined); }, []); const handleTemplateSelect = (name: string) => { const tpl = templates[name]; - if (tpl === null || tpl === undefined) { - // "flat" template — disable profile - return; - } + if (tpl === null || tpl === undefined) return; onProfileChange([...tpl]); onTemplateChange(name); }; - const updateEntry = (month: number, field: "nightly_rate" | "occupancy_pct", value: number) => { - const updated = profile.map((e) => (e.month === month ? { ...e, [field]: value } : e)); + const updateEntry = ( + month: number, + field: "nightly_rate" | "occupancy_pct", + value: number + ) => { + const updated = profile.map((e) => + e.month === month ? { ...e, [field]: value } : e + ); onProfileChange(updated); onTemplateChange("Custom"); }; - // Mini bar chart const maxRate = Math.max(...profile.map((e) => e.nightly_rate), 1); const maxOcc = 100; - const chartH = 80; - const barW = 16; + const chartH = 72; + const barW = 10; const gap = 4; const totalW = 12 * (barW * 2 + gap) + 11 * gap + 24; - // Weighted occupancy const DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - const totalOccupied = profile.reduce((sum, e) => sum + (e.occupancy_pct / 100) * DAYS[e.month - 1], 0); + const totalOccupied = profile.reduce( + (sum, e) => sum + (e.occupancy_pct / 100) * DAYS[e.month - 1], + 0 + ); const effectiveOcc = (totalOccupied / 365) * 100; return ( -
- {/* Template selector */} +
- +
- {/* Mini chart */}
- + {profile.map((e, i) => { const x = i * (barW * 2 + gap + gap) + 12; const rateH = (e.nightly_rate / maxRate) * chartH; const occH = (e.occupancy_pct / maxOcc) * chartH; return ( - - - - {MONTH_NAMES[i]} + + + + {MONTH_NAMES[i].slice(0, 1)} ); })} -
- Rate - Occupancy +
+ + + Rate + + + + Occupancy +
- {/* Editable table */}
- +
- - - - + + + + {profile.map((e) => ( - - + + @@ -692,159 +1149,12 @@ function MonthlyProfileEditor({
MonthNightly Rate ($)Occupancy (%)
Month + Nightly ($) + + Occupancy (%) +
{MONTH_NAMES[e.month - 1]}
+ {MONTH_NAMES[e.month - 1]} + updateEntry(e.month, "nightly_rate", parseFloat(ev.target.value) || 0)} - className="w-24 px-2 py-1 text-right border border-slate-200 dark:border-slate-600 rounded bg-white dark:bg-slate-700 dark:text-slate-100 text-sm" + onChange={(ev) => + updateEntry( + e.month, + "nightly_rate", + parseFloat(ev.target.value) || 0 + ) + } + className="w-24 px-2 py-1 text-right border-b border-rule-strong focus:border-accent focus:outline-none bg-transparent font-mono tabular-nums" /> @@ -682,8 +1133,14 @@ function MonthlyProfileEditor({ max="100" step="1" value={e.occupancy_pct} - onChange={(ev) => updateEntry(e.month, "occupancy_pct", parseFloat(ev.target.value) || 0)} - className="w-20 px-2 py-1 text-right border border-slate-200 dark:border-slate-600 rounded bg-white dark:bg-slate-700 dark:text-slate-100 text-sm" + onChange={(ev) => + updateEntry( + e.month, + "occupancy_pct", + parseFloat(ev.target.value) || 0 + ) + } + className="w-20 px-2 py-1 text-right border-b border-rule-strong focus:border-accent focus:outline-none bg-transparent font-mono tabular-nums" />
-
- Effective Occupancy: +
+ Effective Occupancy:{" "} + {effectiveOcc.toFixed(1)}%
); } - -const LTR_TOOLTIPS = { - monthly_rent: "Expected monthly rent for the property. Research comparable long-term rentals in the area on Zillow, Apartments.com, or local listings.", - lease_duration_months: "Length of the lease in months. Standard is 12 months. Shorter leases (6-9 months) may command higher rent but increase turnover costs.", - pet_rent_monthly: "Additional monthly pet rent. Typical range: $25-$50 per pet. Set to $0 if not accepting pets.", - late_fee_monthly: "Average monthly late fee income across all months. Most landlords charge $50-$100 per late payment; multiply by expected frequency.", - vacancy_rate_pct: "Ongoing vacancy rate as a percentage. Accounts for time between tenants after the initial lease-up. US average: 5-8%. Hot markets: 2-4%.", - lease_up_period_months: "Months to find the first tenant after purchase. Typically 1-2 months. Only affects Year 1 calculations.", - property_mgmt_pct: "Property management fee as % of collected rent. 0% if self-managing. Typical full-service: 8-12%.", - insurance_annual: "Annual landlord insurance premium. Typically $1,500-$3,000. May differ from STR insurance.", - maintenance_reserve_pct: "Percentage of gross revenue set aside for routine maintenance. Standard: 5%. Alternative: 1% of property value per year.", - capex_reserve_pct: "Percentage of gross revenue reserved for major capital expenditures (roof, HVAC, appliances). Standard: 5%.", - landlord_repairs_annual: "Annual budget for landlord-responsible repairs not covered by reserves. Includes plumbing, electrical, appliance repairs.", - tenant_turnover_cost: "Cost to prepare the unit between tenants: cleaning, painting, minor repairs, listing fees. Typical: $1,000-$3,000.", - utilities_monthly: "Monthly utilities paid by the landlord. Many LTR tenants pay their own utilities. Common landlord-paid: water, trash, common area electric.", - lawn_snow_monthly: "Monthly average for landscaping and snow removal. Set to $0 if tenant-responsible or for condos.", - other_monthly_expense: "Catch-all for expenses not covered above: pest control, common area maintenance, etc.", - accounting_annual: "Annual accounting and tax preparation costs. Typical: $500-$1,500.", - legal_annual: "Annual legal costs: lease preparation, eviction proceedings, compliance. Typical: $500-$1,500.", - land_value_pct: "Percentage of purchase price attributable to land (non-depreciable). Check county tax assessment. Typical: 15-30%.", - property_appreciation_pct_annual: "Expected annual property value appreciation. US historical average: 3-4%. Conservative: 2%.", - revenue_growth_pct: "Expected annual rent increase at lease renewal. US average: 3-5%. Tied to market conditions and lease terms.", - expense_growth_pct: "Expected annual expense growth rate. Covers inflation on operating costs. General inflation: 2-4%.", - marginal_tax_rate_pct: "Your combined marginal income tax rate (federal + state). Used to estimate after-tax cashflow. Set to 0 to hide tax analysis.", -}; - -function LTRForm({ ltrForm, updateLTRField }: { - ltrForm: LTRAssumptions; - updateLTRField: (key: K, value: LTRAssumptions[K]) => void; -}) { - return ( -
- {/* Left column: Revenue */} -
-

LTR Revenue

- updateLTRField("monthly_rent", v)} - tooltip={LTR_TOOLTIPS.monthly_rent} - /> -
- - updateLTRField("lease_duration_months", parseInt(e.target.value) || 12)} - className="w-full px-3 py-2 border border-slate-200 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:text-slate-100" - /> -
- updateLTRField("pet_rent_monthly", v)} - tooltip={LTR_TOOLTIPS.pet_rent_monthly} - /> - updateLTRField("late_fee_monthly", v)} - tooltip={LTR_TOOLTIPS.late_fee_monthly} - /> - updateLTRField("vacancy_rate_pct", v)} - tooltip={LTR_TOOLTIPS.vacancy_rate_pct} - /> -
- - updateLTRField("lease_up_period_months", parseInt(e.target.value) || 0)} - className="w-full px-3 py-2 border border-slate-200 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:text-slate-100" - /> -
- -
- - {/* Right column: Expenses */} -
-

LTR Expenses

- - {/* Management */} -
-

Management

- updateLTRField("property_mgmt_pct", v)} - tooltip={LTR_TOOLTIPS.property_mgmt_pct} - /> -
- - {/* Turnover */} -
-

Turnover

- updateLTRField("tenant_turnover_cost", v)} - tooltip={LTR_TOOLTIPS.tenant_turnover_cost} - /> -
- - {/* Monthly Fixed */} -
-

Monthly Fixed

-
- updateLTRField("utilities_monthly", v)} - tooltip={LTR_TOOLTIPS.utilities_monthly} - /> -
-
- - {/* Annual (LTR-specific) */} -
-

Annual

-
- updateLTRField("landlord_repairs_annual", v)} - tooltip={LTR_TOOLTIPS.landlord_repairs_annual} - /> -
-
- -
-
- ); -} diff --git a/frontend/src/components/shared/SeasonalBars.tsx b/frontend/src/components/shared/SeasonalBars.tsx new file mode 100644 index 0000000..15f6bf3 --- /dev/null +++ b/frontend/src/components/shared/SeasonalBars.tsx @@ -0,0 +1,74 @@ +interface SeasonalBarsProps { + values: number[]; + peak?: boolean[]; + height?: number; + className?: string; + ariaLabel?: string; + valueSuffix?: string; +} + +const MONTH_LABELS = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]; + +export function SeasonalBars({ + values, + peak, + height = 96, + className = "", + ariaLabel = "Monthly values", + valueSuffix = "", +}: SeasonalBarsProps) { + if (values.length !== 12) return null; + + const max = Math.max(...values, 1); + const barW = 28; + const gap = 8; + const width = 12 * barW + 11 * gap; + const labelY = height - 2; + const chartH = height - 18; + + return ( +
+ + {values.map((v, i) => { + const h = Math.max(1, (v / max) * chartH); + const x = i * (barW + gap); + const y = chartH - h; + const isPeak = peak?.[i] ?? false; + return ( + + + {MONTH_LABELS[i]}: {v.toFixed(1)} + {valueSuffix} + + + + {MONTH_LABELS[i]} + + + ); + })} + +
+ ); +} +