Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getTimeTrackingSettings, getTimeTrackingWorkers, getWorkerTimeLogs } from "@/actions/time-tracking";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
|
||||
type LogEntry = {
|
||||
id: string;
|
||||
worker_id: string;
|
||||
worker_name: string;
|
||||
task_id: string | null;
|
||||
task_name: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
lunch_break_minutes: number;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
total_minutes: number;
|
||||
created_at: string;
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
|
||||
function csvRow(values: (string | number | boolean | null)[]): string {
|
||||
return values.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",");
|
||||
}
|
||||
|
||||
// Determine current pay period from settings
|
||||
function getPayPeriodDates(
|
||||
startDay: number,
|
||||
lengthDays: number
|
||||
): { start: string; end: string } {
|
||||
const now = new Date();
|
||||
const currentDayOfWeek = now.getDay(); // 0=Sun
|
||||
const daysSinceStartDay = (currentDayOfWeek - startDay + 7) % 7;
|
||||
const periodStart = new Date(now);
|
||||
periodStart.setDate(now.getDate() - daysSinceStartDay - (lengthDays - 1));
|
||||
const periodEnd = new Date(periodStart);
|
||||
periodEnd.setDate(periodStart.getDate() + lengthDays - 1);
|
||||
return {
|
||||
start: formatDate(periodStart.toISOString()),
|
||||
end: formatDate(periodEnd.toISOString()),
|
||||
};
|
||||
}
|
||||
|
||||
type ExportType = "quickbooks" | "payroll" | "detailed" | "summary";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const exportType = (searchParams.get("type") ?? "detailed") as ExportType;
|
||||
const brandFilter = searchParams.get("brand") ?? null; // "tuxedo" | "ird" | null
|
||||
const startDate = searchParams.get("start") ?? "";
|
||||
const endDate = searchParams.get("end") ?? "";
|
||||
const includeOvertime = searchParams.get("overtime") !== "false";
|
||||
const includeNotes = searchParams.get("notes") !== "false";
|
||||
|
||||
// Determine brand IDs to export
|
||||
let brandIds: string[] = [];
|
||||
if (brandFilter === "tuxedo") brandIds = [TUXEDO_BRAND_ID];
|
||||
else if (brandFilter === "ird") brandIds = [IRD_BRAND_ID];
|
||||
else if (adminUser.brand_id) brandIds = [adminUser.brand_id];
|
||||
else brandIds = [TUXEDO_BRAND_ID, IRD_BRAND_ID];
|
||||
|
||||
// Collect all logs across brands
|
||||
let allLogs: LogEntry[] = [];
|
||||
let allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
|
||||
let allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
|
||||
|
||||
for (const brandId of brandIds) {
|
||||
const [workers, settings, logs] = await Promise.all([
|
||||
getTimeTrackingWorkers(brandId),
|
||||
getTimeTrackingSettings(brandId),
|
||||
getWorkerTimeLogs(brandId, {
|
||||
start: startDate || undefined,
|
||||
end: endDate || undefined,
|
||||
limit: 5000,
|
||||
}),
|
||||
]);
|
||||
allWorkers.push(workers);
|
||||
allSettings.push(settings);
|
||||
allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId })));
|
||||
}
|
||||
|
||||
// Sort by clock_in
|
||||
allLogs.sort((a, b) => new Date(a.clock_in).getTime() - new Date(b.clock_in).getTime());
|
||||
|
||||
// Default to current pay period if no dates given
|
||||
const defaultPeriod = getPayPeriodDates(0, 7);
|
||||
const start = startDate || defaultPeriod.start;
|
||||
const end = endDate || defaultPeriod.end;
|
||||
|
||||
// Filter by date range
|
||||
allLogs = allLogs.filter(l => {
|
||||
const d = formatDate(l.clock_in);
|
||||
return d >= start && d <= end;
|
||||
});
|
||||
|
||||
// Helper to compute overtime for a log entry
|
||||
function computeOvertime(log: (typeof allLogs)[0], settings: NonNullable<typeof allSettings[0]>) {
|
||||
if (!includeOvertime) return { regularHours: 0, overtimeHours: 0 };
|
||||
const totalHours = log.total_minutes / 60;
|
||||
const dailyThreshold = Number(settings.daily_overtime_threshold);
|
||||
const weeklyThreshold = Number(settings.weekly_overtime_threshold);
|
||||
// Compute which threshold was hit
|
||||
const overtimeHours = Math.max(0, totalHours - dailyThreshold);
|
||||
const regularHours = totalHours - overtimeHours;
|
||||
return { regularHours, overtimeHours };
|
||||
}
|
||||
|
||||
let csv = "";
|
||||
let filename = "";
|
||||
|
||||
if (exportType === "quickbooks") {
|
||||
// QuickBooks Time import format
|
||||
// Columns: Employee Name, Date, Clock In, Clock Out, Hours, Task/Service Item, Overtime Hours, Notes
|
||||
const headers = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"];
|
||||
const rows = allLogs.map((log, i) => {
|
||||
const worker = allWorkers.flat().find(w => w.id === log.worker_id);
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
|
||||
const totalHours = log.total_minutes / 60;
|
||||
return [
|
||||
log.worker_name,
|
||||
formatDate(log.clock_in),
|
||||
formatTime(log.clock_in),
|
||||
log.clock_out ? formatTime(log.clock_out) : "",
|
||||
totalHours.toFixed(2),
|
||||
log.task_name,
|
||||
overtimeHours > 0 ? overtimeHours.toFixed(2) : "",
|
||||
log.notes ?? "",
|
||||
];
|
||||
});
|
||||
csv = [headers, ...rows].map(csvRow).join("\n");
|
||||
filename = `time-tracking-quickbooks-${start}-to-${end}.csv`;
|
||||
}
|
||||
else if (exportType === "payroll") {
|
||||
// Standard payroll export — grouped by worker with totals
|
||||
// Columns: Worker Name, Role, Total Hours, Regular Hours, Overtime Hours, Days Worked, Tasks
|
||||
const workerMap = new Map<string, {
|
||||
name: string; role: string; totalMin: number; days: Set<string>; tasks: Set<string>;
|
||||
dailyOTMin: number; weeklyOTMin: number;
|
||||
}>();
|
||||
for (const log of allLogs) {
|
||||
if (!workerMap.has(log.worker_id)) {
|
||||
const w = allWorkers.flat().find(w => w.id === log.worker_id);
|
||||
workerMap.set(log.worker_id, {
|
||||
name: log.worker_name,
|
||||
role: w?.role ?? "worker",
|
||||
totalMin: 0, days: new Set(), tasks: new Set(),
|
||||
dailyOTMin: 0, weeklyOTMin: 0,
|
||||
});
|
||||
}
|
||||
const entry = workerMap.get(log.worker_id)!;
|
||||
entry.totalMin += log.total_minutes;
|
||||
entry.days.add(formatDate(log.clock_in));
|
||||
entry.tasks.add(log.task_name);
|
||||
}
|
||||
const headers = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"];
|
||||
const rows = [...workerMap.values()].map(e => {
|
||||
const totalH = e.totalMin / 60;
|
||||
const otH = Math.max(0, totalH - 40); // weekly OT at 40h
|
||||
const regH = totalH - otH;
|
||||
return [
|
||||
e.name,
|
||||
e.role,
|
||||
e.days.size,
|
||||
totalH.toFixed(2),
|
||||
regH.toFixed(2),
|
||||
otH > 0 ? otH.toFixed(2) : "",
|
||||
[...e.tasks].join("; "),
|
||||
];
|
||||
});
|
||||
csv = [headers, ...rows].map(csvRow).join("\n");
|
||||
filename = `time-tracking-payroll-${start}-to-${end}.csv`;
|
||||
}
|
||||
else if (exportType === "summary") {
|
||||
// Pay period summary — one row per worker per pay period
|
||||
const brandName = allSettings[0]?.brand_name ?? "Farm";
|
||||
const headers = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"];
|
||||
const workerMap = new Map<string, {
|
||||
brandName: string; name: string; totalMin: number; dailyOT: number; weeklyOT: number;
|
||||
}>();
|
||||
for (const log of allLogs) {
|
||||
if (!workerMap.has(log.worker_id)) {
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
workerMap.set(log.worker_id, {
|
||||
brandName: settings?.brand_name ?? brandName,
|
||||
name: log.worker_name,
|
||||
totalMin: 0, dailyOT: 0, weeklyOT: 0,
|
||||
});
|
||||
}
|
||||
const entry = workerMap.get(log.worker_id)!;
|
||||
const totalH = log.total_minutes / 60;
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
if (settings) {
|
||||
const dailyThr = Number(settings.daily_overtime_threshold);
|
||||
const weeklyThr = Number(settings.weekly_overtime_threshold);
|
||||
if (totalH > dailyThr) entry.dailyOT += (totalH - dailyThr);
|
||||
if (totalH > weeklyThr) entry.weeklyOT += (totalH - weeklyThr);
|
||||
}
|
||||
entry.totalMin += log.total_minutes;
|
||||
}
|
||||
const rows = [...workerMap.values()].map(e => {
|
||||
const totalH = e.totalMin / 60;
|
||||
return [
|
||||
e.brandName,
|
||||
e.name,
|
||||
start, end,
|
||||
totalH.toFixed(2),
|
||||
e.dailyOT > 0 ? e.dailyOT.toFixed(2) : "",
|
||||
e.weeklyOT > 0 ? e.weeklyOT.toFixed(2) : "",
|
||||
e.weeklyOT > 0 ? "OVERTIME" : totalH > 40 ? "NEAR OT" : "OK",
|
||||
];
|
||||
});
|
||||
csv = [headers, ...rows].map(csvRow).join("\n");
|
||||
filename = `time-tracking-summary-${start}-to-${end}.csv`;
|
||||
}
|
||||
else {
|
||||
// Detailed audit log
|
||||
const headers = [
|
||||
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
|
||||
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
|
||||
"Submitted Via", "Notes", "Brand",
|
||||
];
|
||||
const rows = allLogs.map(log => {
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
|
||||
return [
|
||||
log.worker_name,
|
||||
log.task_name,
|
||||
formatDate(log.clock_in),
|
||||
formatTime(log.clock_in),
|
||||
log.clock_out ? formatTime(log.clock_out) : "",
|
||||
log.lunch_break_minutes,
|
||||
log.total_minutes,
|
||||
(log.total_minutes / 60).toFixed(2),
|
||||
overtimeHours > 0 ? overtimeHours.toFixed(2) : "",
|
||||
log.submitted_via,
|
||||
log.notes ?? "",
|
||||
allSettings.find(s => s && s.brand_id === log.brandId)?.brand_name ?? "",
|
||||
];
|
||||
});
|
||||
csv = [headers, ...rows].map(csvRow).join("\n");
|
||||
filename = `time-tracking-detailed-${start}-to-${end}.csv`;
|
||||
}
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user