Skip to content

Commit 3dffe18

Browse files
committed
add more complex tax calculations and an allocation calendar for the pdf
1 parent faaceaa commit 3dffe18

12 files changed

Lines changed: 1024 additions & 176 deletions
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
import React, { useState } from 'react';
2+
import type { ResultData, FormData } from '../types';
3+
import { formatCurrency } from '../utils';
4+
5+
interface IncomeAllocationCalendarProps {
6+
results: ResultData[];
7+
formData: FormData;
8+
}
9+
10+
interface DayAllocation {
11+
date: Date;
12+
state: string;
13+
regularIncome: number;
14+
bonusIncome: number;
15+
totalIncome: number;
16+
isPrimary: boolean;
17+
}
18+
19+
interface CalendarMonth {
20+
year: number;
21+
month: number;
22+
days: (DayAllocation | null)[];
23+
}
24+
25+
const STATE_COLORS = {
26+
primary: 'bg-blue-100 border-blue-300 dark:bg-blue-900 dark:border-blue-600',
27+
other: 'bg-green-100 border-green-300 dark:bg-green-900 dark:border-green-600',
28+
bonus: 'bg-purple-100 border-purple-300 dark:bg-purple-900 dark:border-purple-600'
29+
};
30+
31+
export const IncomeAllocationCalendar: React.FC<IncomeAllocationCalendarProps> = ({
32+
results,
33+
formData
34+
}) => {
35+
const [currentMonthIndex, setCurrentMonthIndex] = useState(0);
36+
37+
// Helper function to check if a date is a weekday
38+
const isWeekday = (date: Date): boolean => {
39+
const day = date.getDay();
40+
return day !== 0 && day !== 6; // 0 = Sunday, 6 = Saturday
41+
};
42+
43+
// Generate daily allocations from results
44+
const generateDailyAllocations = (): DayAllocation[] => {
45+
const dailyAllocations: DayAllocation[] = [];
46+
const primaryState = formData.primaryState;
47+
48+
// Create a map of other state days
49+
const otherStateDaysMap = new Map<string, string>();
50+
for (const state in formData.daysInOtherStates) {
51+
for (const dateStr of formData.daysInOtherStates[state]) {
52+
if (dateStr) {
53+
otherStateDaysMap.set(dateStr, state);
54+
}
55+
}
56+
}
57+
58+
// Process each pay period
59+
results.forEach(({ period, result }) => {
60+
const ppStart = new Date(period.payPeriodStart + 'T00:00:00');
61+
const ppEnd = new Date(period.payPeriodEnd + 'T00:00:00');
62+
const primaryVisitStart = new Date(formData.visitingDates.start + 'T00:00:00');
63+
const primaryVisitEnd = new Date(formData.visitingDates.end + 'T00:00:00');
64+
65+
// Calculate total working days for this period
66+
let totalWorkingDays = 0;
67+
for (let d = new Date(ppStart); d <= ppEnd; d.setDate(d.getDate() + 1)) {
68+
if (isWeekday(d)) {
69+
const dateStr = d.toISOString().split('T')[0];
70+
if (otherStateDaysMap.has(dateStr) || (d >= primaryVisitStart && d <= primaryVisitEnd)) {
71+
totalWorkingDays++;
72+
}
73+
}
74+
}
75+
76+
const dailyRegularRate = totalWorkingDays > 0 ? parseFloat(String(period.netPay || 0)) / totalWorkingDays : 0;
77+
78+
// Process each day in the pay period
79+
for (let d = new Date(ppStart); d <= ppEnd; d.setDate(d.getDate() + 1)) {
80+
if (!isWeekday(d)) continue;
81+
82+
const dateStr = d.toISOString().split('T')[0];
83+
let dayState = '';
84+
let regularIncome = 0;
85+
let bonusIncome = 0;
86+
87+
// Determine which state this day belongs to
88+
if (otherStateDaysMap.has(dateStr)) {
89+
dayState = otherStateDaysMap.get(dateStr)!;
90+
} else if (d >= primaryVisitStart && d <= primaryVisitEnd) {
91+
dayState = primaryState;
92+
} else {
93+
continue; // Not a working day
94+
}
95+
96+
// Calculate regular income for this day
97+
regularIncome = dailyRegularRate;
98+
99+
// Add any bonuses for this specific date
100+
formData.bonuses.forEach(bonus => {
101+
if (bonus.date === dateStr && bonus.state === dayState) {
102+
bonusIncome += parseFloat(String(bonus.amount || 0));
103+
}
104+
});
105+
106+
dailyAllocations.push({
107+
date: new Date(d),
108+
state: dayState,
109+
regularIncome,
110+
bonusIncome,
111+
totalIncome: regularIncome + bonusIncome,
112+
isPrimary: dayState === primaryState
113+
});
114+
}
115+
});
116+
117+
return dailyAllocations.sort((a, b) => a.date.getTime() - b.date.getTime());
118+
};
119+
120+
// Group daily allocations into months
121+
const groupIntoMonths = (dailyAllocations: DayAllocation[]): CalendarMonth[] => {
122+
if (dailyAllocations.length === 0) return [];
123+
124+
const months: CalendarMonth[] = [];
125+
let currentMonth: CalendarMonth | null = null;
126+
127+
dailyAllocations.forEach(allocation => {
128+
const year = allocation.date.getFullYear();
129+
const month = allocation.date.getMonth();
130+
131+
// Check if we need to create a new month
132+
if (!currentMonth || currentMonth.year !== year || currentMonth.month !== month) {
133+
// Add the previous month to the array if it exists
134+
if (currentMonth) {
135+
months.push(currentMonth);
136+
}
137+
138+
// Create a new month
139+
currentMonth = {
140+
year,
141+
month,
142+
days: []
143+
};
144+
145+
// Fill the calendar grid (weekdays only)
146+
const firstDay = new Date(year, month, 1);
147+
const lastDay = new Date(year, month + 1, 0);
148+
149+
// Initialize calendar grid (5 weekdays per week, up to 6 weeks)
150+
for (let week = 0; week < 6; week++) {
151+
for (let weekday = 1; weekday <= 5; weekday++) { // Monday to Friday
152+
currentMonth.days.push(null);
153+
}
154+
}
155+
}
156+
157+
// Place the allocation in the correct position in the grid
158+
const dayOfMonth = allocation.date.getDate();
159+
const firstDayOfMonth = new Date(allocation.date.getFullYear(), allocation.date.getMonth(), 1);
160+
const firstWeekday = firstDayOfMonth.getDay() === 0 ? 7 : firstDayOfMonth.getDay(); // Convert Sunday to 7
161+
const weekdayOfMonth = allocation.date.getDay() === 0 ? 7 : allocation.date.getDay(); // Convert Sunday to 7
162+
163+
// Only place weekdays (Monday = 1, Friday = 5)
164+
if (weekdayOfMonth >= 1 && weekdayOfMonth <= 5) {
165+
const weeksFromFirst = Math.floor((dayOfMonth - 1 + firstWeekday - 1) / 7);
166+
const position = weeksFromFirst * 5 + (weekdayOfMonth - 1); // weekdayOfMonth 1-5 becomes 0-4
167+
168+
if (position >= 0 && position < currentMonth.days.length) {
169+
currentMonth.days[position] = allocation;
170+
}
171+
}
172+
});
173+
174+
// Add the last month if it exists
175+
if (currentMonth) {
176+
months.push(currentMonth);
177+
}
178+
179+
return months;
180+
};
181+
182+
const dailyAllocations = generateDailyAllocations();
183+
const months = groupIntoMonths(dailyAllocations);
184+
185+
if (months.length === 0) {
186+
return (
187+
<div className="text-center py-8 text-zinc-500 dark:text-zinc-400">
188+
No income allocation data to display.
189+
</div>
190+
);
191+
}
192+
193+
const currentMonth = months[currentMonthIndex] || months[0];
194+
195+
// Navigation functions
196+
const canNavigatePrevious = currentMonthIndex > 0;
197+
const canNavigateNext = currentMonthIndex < months.length - 1;
198+
199+
const navigateToPrevious = () => {
200+
if (canNavigatePrevious) {
201+
setCurrentMonthIndex(currentMonthIndex - 1);
202+
}
203+
};
204+
205+
const navigateToNext = () => {
206+
if (canNavigateNext) {
207+
setCurrentMonthIndex(currentMonthIndex + 1);
208+
}
209+
};
210+
211+
const getStateColor = (allocation: DayAllocation): string => {
212+
if (allocation.bonusIncome > 0) return STATE_COLORS.bonus;
213+
return allocation.isPrimary ? STATE_COLORS.primary : STATE_COLORS.other;
214+
};
215+
216+
return (
217+
<div className="space-y-4">
218+
<div className="flex items-center justify-between">
219+
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white">
220+
Income Allocation Calendar
221+
</h3>
222+
<div className="text-sm text-zinc-600 dark:text-zinc-400">
223+
Showing weekdays only
224+
</div>
225+
</div>
226+
227+
{/* Month navigation */}
228+
<div className="flex items-center justify-between">
229+
<button
230+
onClick={navigateToPrevious}
231+
disabled={!canNavigatePrevious}
232+
className={`p-2 rounded-md transition-colors ${
233+
canNavigatePrevious
234+
? 'hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300'
235+
: 'text-zinc-300 dark:text-zinc-600 cursor-not-allowed'
236+
}`}
237+
>
238+
← Prev
239+
</button>
240+
241+
<h4 className="text-md font-medium text-zinc-900 dark:text-white">
242+
{new Date(currentMonth.year, currentMonth.month).toLocaleDateString('en-US', {
243+
month: 'long',
244+
year: 'numeric'
245+
})}
246+
</h4>
247+
248+
<button
249+
onClick={navigateToNext}
250+
disabled={!canNavigateNext}
251+
className={`p-2 rounded-md transition-colors ${
252+
canNavigateNext
253+
? 'hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300'
254+
: 'text-zinc-300 dark:text-zinc-600 cursor-not-allowed'
255+
}`}
256+
>
257+
Next →
258+
</button>
259+
</div>
260+
261+
{/* Legend */}
262+
<div className="flex flex-wrap gap-4 text-xs text-zinc-600 dark:text-zinc-400">
263+
<div className="flex items-center gap-2">
264+
<div className={`w-3 h-3 rounded border ${STATE_COLORS.primary}`}></div>
265+
<span>Primary State ({formData.primaryState})</span>
266+
</div>
267+
<div className="flex items-center gap-2">
268+
<div className={`w-3 h-3 rounded border ${STATE_COLORS.other}`}></div>
269+
<span>Other States</span>
270+
</div>
271+
<div className="flex items-center gap-2">
272+
<div className={`w-3 h-3 rounded border ${STATE_COLORS.bonus}`}></div>
273+
<span>Bonus Income</span>
274+
</div>
275+
</div>
276+
277+
{/* Week headers */}
278+
<div className="grid grid-cols-5 gap-1 mb-2">
279+
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].map(day => (
280+
<div key={day} className="text-center text-xs font-medium text-zinc-500 dark:text-zinc-400 py-1">
281+
{day}
282+
</div>
283+
))}
284+
</div>
285+
286+
{/* Calendar grid (6 weeks x 5 weekdays) */}
287+
<div className="grid grid-cols-5 gap-1">
288+
{currentMonth.days.map((allocation, index) => {
289+
const weekIndex = Math.floor(index / 5);
290+
const dayIndex = index % 5;
291+
292+
if (!allocation) {
293+
return (
294+
<div
295+
key={`${weekIndex}-${dayIndex}`}
296+
className="h-16 border border-transparent"
297+
></div>
298+
);
299+
}
300+
301+
return (
302+
<div
303+
key={`${weekIndex}-${dayIndex}`}
304+
className={`
305+
h-16 border rounded p-1 text-xs
306+
${getStateColor(allocation)}
307+
transition-all duration-150
308+
`}
309+
>
310+
<div className="font-semibold text-zinc-800 dark:text-zinc-200">
311+
{allocation.date.getDate()}
312+
</div>
313+
<div className="text-zinc-700 dark:text-zinc-300 font-medium text-[10px]">
314+
{allocation.state}
315+
</div>
316+
<div className="text-zinc-600 dark:text-zinc-400 text-[9px] leading-tight">
317+
{allocation.totalIncome > 0 && formatCurrency(allocation.totalIncome)}
318+
</div>
319+
{allocation.bonusIncome > 0 && (
320+
<div className="text-purple-700 dark:text-purple-300 text-[8px]">
321+
+{formatCurrency(allocation.bonusIncome)} bonus
322+
</div>
323+
)}
324+
</div>
325+
);
326+
})}
327+
</div>
328+
329+
{/* Monthly summary */}
330+
<div className="mt-6 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
331+
<h5 className="text-sm font-medium text-zinc-900 dark:text-white mb-2">
332+
Monthly Summary
333+
</h5>
334+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
335+
{(() => {
336+
const monthlyTotals: Record<string, { regular: number; bonus: number; days: number }> = {};
337+
338+
currentMonth.days.forEach(allocation => {
339+
if (allocation) {
340+
if (!monthlyTotals[allocation.state]) {
341+
monthlyTotals[allocation.state] = { regular: 0, bonus: 0, days: 0 };
342+
}
343+
monthlyTotals[allocation.state].regular += allocation.regularIncome;
344+
monthlyTotals[allocation.state].bonus += allocation.bonusIncome;
345+
monthlyTotals[allocation.state].days += 1;
346+
}
347+
});
348+
349+
return Object.entries(monthlyTotals)
350+
.sort(([a], [b]) => {
351+
if (a === formData.primaryState) return -1;
352+
if (b === formData.primaryState) return 1;
353+
return a.localeCompare(b);
354+
})
355+
.map(([state, totals]) => (
356+
<div key={state} className="text-zinc-700 dark:text-zinc-300">
357+
<div className="font-medium">{state}</div>
358+
<div>{formatCurrency(totals.regular + totals.bonus)}</div>
359+
<div className="text-zinc-500 dark:text-zinc-400">{totals.days} days</div>
360+
</div>
361+
));
362+
})()}
363+
</div>
364+
</div>
365+
</div>
366+
);
367+
};

0 commit comments

Comments
 (0)