139 lines
4.1 KiB
TypeScript
139 lines
4.1 KiB
TypeScript
/** Mirrors PleasePayMe.Application.Jobs.PaySchedule for calendar rendering. */
|
|
|
|
export type WeekendPolicy = "before_weekend" | "after_weekend";
|
|
|
|
export type PayOccurrence = {
|
|
date: string;
|
|
jobId: number;
|
|
jobName: string;
|
|
scheduledDay: number;
|
|
percent: number;
|
|
amount: number;
|
|
};
|
|
|
|
function pad2(n: number): string {
|
|
return String(n).padStart(2, "0");
|
|
}
|
|
|
|
export function toIso(year: number, month: number, day: number): string {
|
|
return `${year}-${pad2(month)}-${pad2(day)}`;
|
|
}
|
|
|
|
export function parseIso(iso: string): { year: number; month: number; day: number } {
|
|
const [year, month, day] = iso.slice(0, 10).split("-").map(Number);
|
|
return { year, month, day };
|
|
}
|
|
|
|
export function addDaysIso(iso: string, delta: number): string {
|
|
const { year, month, day } = parseIso(iso);
|
|
const dt = new Date(Date.UTC(year, month - 1, day + delta));
|
|
return toIso(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate());
|
|
}
|
|
|
|
export function weekdayMon0(iso: string): number {
|
|
const { year, month, day } = parseIso(iso);
|
|
// 0 = Mon … 6 = Sun
|
|
const js = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
return (js + 6) % 7;
|
|
}
|
|
|
|
export function adjustForWeekend(iso: string, policy: WeekendPolicy): string {
|
|
const wd = weekdayMon0(iso);
|
|
// Mon=0 … Sat=5, Sun=6
|
|
if (wd === 5) {
|
|
return policy === "before_weekend" ? addDaysIso(iso, -1) : addDaysIso(iso, 2);
|
|
}
|
|
if (wd === 6) {
|
|
return policy === "before_weekend" ? addDaysIso(iso, -2) : addDaysIso(iso, 1);
|
|
}
|
|
return iso;
|
|
}
|
|
|
|
export function nominalDate(year: number, month: number, dayOfMonth: number): string {
|
|
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
const actualDay = Math.min(dayOfMonth, daysInMonth);
|
|
return toIso(year, month, actualDay);
|
|
}
|
|
|
|
export function paysInRange(
|
|
jobs: Array<{
|
|
id: number;
|
|
name: string;
|
|
salary_amount: number;
|
|
pay_days: number[];
|
|
first_pay_percent: number;
|
|
weekend_policy: WeekendPolicy;
|
|
is_active: boolean;
|
|
}>,
|
|
rangeStart: string,
|
|
rangeEnd: string,
|
|
): PayOccurrence[] {
|
|
const start = parseIso(rangeStart);
|
|
const end = parseIso(rangeEnd);
|
|
const result: PayOccurrence[] = [];
|
|
|
|
for (const job of jobs) {
|
|
if (!job.is_active || job.pay_days.length === 0) continue;
|
|
|
|
const ordered = [...job.pay_days].sort((a, b) => a - b).slice(0, 2);
|
|
const firstPercent = ordered.length <= 1 ? 100 : job.first_pay_percent;
|
|
const secondPercent = ordered.length === 1 ? 0 : 100 - firstPercent;
|
|
|
|
// Extra months: weekend shift can move pay across month boundary.
|
|
let cursor = new Date(Date.UTC(start.year, start.month - 2, 1));
|
|
const last = new Date(Date.UTC(end.year, end.month, 1));
|
|
|
|
while (cursor <= last) {
|
|
const year = cursor.getUTCFullYear();
|
|
const month = cursor.getUTCMonth() + 1;
|
|
|
|
ordered.forEach((scheduledDay, i) => {
|
|
const nominal = nominalDate(year, month, scheduledDay);
|
|
const actual = adjustForWeekend(nominal, job.weekend_policy);
|
|
if (actual < rangeStart || actual > rangeEnd) return;
|
|
|
|
const percent = i === 0 ? firstPercent : secondPercent;
|
|
const amount = Math.round((job.salary_amount * percent) / 100 * 100) / 100;
|
|
result.push({
|
|
date: actual,
|
|
jobId: job.id,
|
|
jobName: job.name,
|
|
scheduledDay,
|
|
percent,
|
|
amount,
|
|
});
|
|
});
|
|
|
|
cursor = new Date(Date.UTC(year, month, 1));
|
|
}
|
|
}
|
|
|
|
return result.sort((a, b) =>
|
|
a.date === b.date
|
|
? a.jobId - b.jobId || a.scheduledDay - b.scheduledDay
|
|
: a.date < b.date
|
|
? -1
|
|
: 1,
|
|
);
|
|
}
|
|
|
|
export function monthGrid(year: number, month: number): string[] {
|
|
const first = toIso(year, month, 1);
|
|
const startOffset = weekdayMon0(first);
|
|
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
const cells: string[] = [];
|
|
|
|
for (let i = 0; i < startOffset; i++) {
|
|
cells.push(addDaysIso(first, i - startOffset));
|
|
}
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
cells.push(toIso(year, month, d));
|
|
}
|
|
while (cells.length % 7 !== 0 || cells.length < 35) {
|
|
const last = cells[cells.length - 1]!;
|
|
cells.push(addDaysIso(last, 1));
|
|
if (cells.length >= 42) break;
|
|
}
|
|
return cells;
|
|
}
|