Files
please-pay-me/web/src/lib/paySchedule.ts
T
2026-09-21 04:06:43 +03:00

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;
}