Files
coastal_timesheet/backend/src/utils/pdf.js
T
BizzleBot f718a76153 Coastal Timesheet v2 — full feature buildout
Features:
- Copy Previous Week (entries duplication)
- Bulk Approve/Reject (admin workflow)
- Overtime tracking (employee + admin views)
- DayCard component with auto-save
- PDF generation, email notifications
- React + Tailwind frontend, Prisma + PostgreSQL backend
- Docker deployment (3 containers)
2026-02-16 10:01:39 +00:00

357 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const React = require('react');
const {
Document,
Page,
Text,
View,
StyleSheet,
renderToBuffer,
} = require('@react-pdf/renderer');
const styles = StyleSheet.create({
page: {
fontFamily: 'Helvetica',
fontSize: 10,
paddingTop: 25,
paddingBottom: 40,
paddingHorizontal: 30,
backgroundColor: '#ffffff',
},
headerSection: {
marginBottom: 20,
paddingBottom: 10,
borderBottomWidth: 2,
borderBottomColor: '#e5e7eb',
},
header: {
fontSize: 20,
marginBottom: 6,
textAlign: 'center',
fontWeight: 'bold',
color: '#1f2937',
letterSpacing: 1.0,
},
brandLine: {
width: 60,
height: 3,
backgroundColor: '#3b82f6',
alignSelf: 'center',
marginBottom: 5,
},
weekInfo: {
fontSize: 14,
marginBottom: 18,
textAlign: 'center',
fontWeight: 'bold',
color: '#374151',
backgroundColor: '#f8fafc',
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 4,
},
employeeInfo: {
fontSize: 14,
marginBottom: 12,
textAlign: 'center',
fontWeight: 'bold',
color: '#374151',
backgroundColor: '#f0f9ff',
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 4,
},
statusBadge: {
fontSize: 10,
textAlign: 'center',
marginBottom: 12,
paddingVertical: 4,
paddingHorizontal: 12,
borderRadius: 4,
alignSelf: 'center',
},
daySection: {
marginBottom: 8,
borderRadius: 4,
overflow: 'hidden',
border: '1px solid #e5e7eb',
},
dayHeader: {
backgroundColor: '#3b82f6',
paddingVertical: 6,
paddingHorizontal: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dayName: {
fontSize: 11,
fontWeight: 'bold',
color: '#ffffff',
},
dayDate: {
fontSize: 9,
color: '#dbeafe',
},
dayTotal: {
fontSize: 9,
color: '#dbeafe',
fontWeight: 'bold',
},
entryRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
minHeight: 28,
},
entryRowLast: {
borderBottomWidth: 0,
},
entryRowAlternate: {
backgroundColor: '#f9fafb',
},
entryCell: {
paddingVertical: 6,
paddingHorizontal: 8,
fontSize: 9,
color: '#374151',
justifyContent: 'center',
},
homeownerCell: {
width: '25%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
fontWeight: 'bold',
color: '#1f2937',
},
hoursCell: {
width: '15%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
alignItems: 'center',
fontWeight: 'bold',
color: '#059669',
},
workDescCell: {
width: '60%',
},
summarySection: {
marginTop: 18,
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: '#3b82f6',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
summaryText: {
fontSize: 12,
fontWeight: 'bold',
color: '#1f2937',
},
totalHours: {
fontSize: 16,
fontWeight: 'bold',
color: '#059669',
},
footer: {
position: 'absolute',
fontSize: 8,
bottom: 25,
left: 0,
right: 0,
textAlign: 'center',
color: '#9ca3af',
},
});
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
/**
* Get the 7 days of the week (Monday → Sunday) from a Monday date string
*/
function getWeekDays(mondayStr) {
const days = [];
const start = new Date(mondayStr + 'T00:00:00Z');
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setUTCDate(d.getUTCDate() + i);
days.push(d);
}
return days;
}
function formatDate(date) {
const m = date.getUTCMonth() + 1;
const d = date.getUTCDate();
const y = date.getUTCFullYear();
return `${m}/${d}/${y}`;
}
function formatWeekRange(weekDays) {
if (!weekDays.length) return '';
const first = weekDays[0];
const last = weekDays[weekDays.length - 1];
const opts = { month: 'short', day: 'numeric' };
const startStr = first.toLocaleDateString('en-US', { ...opts, timeZone: 'UTC' });
const endStr = last.toLocaleDateString('en-US', { ...opts, year: 'numeric', timeZone: 'UTC' });
return `${startStr} – ${endStr}`;
}
function getStatusColor(status) {
switch (status) {
case 'approved': return { bg: '#dcfce7', text: '#166534' };
case 'submitted': return { bg: '#dbeafe', text: '#1e40af' };
case 'rejected': return { bg: '#fef2f2', text: '#991b1b' };
default: return { bg: '#f3f4f6', text: '#374151' };
}
}
/**
* Build the React-PDF document element
*/
function buildTimesheetDocument({ userName, weekStart, entries, status }) {
const weekDays = getWeekDays(weekStart);
// Group entries by date
const entriesByDate = {};
for (const entry of entries) {
const dateKey = typeof entry.date === 'string'
? entry.date
: entry.date.toISOString().split('T')[0];
if (!entriesByDate[dateKey]) entriesByDate[dateKey] = [];
entriesByDate[dateKey].push(entry);
}
// Total hours
const totalHours = entries.reduce(
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
0
);
const statusColors = getStatusColor(status);
const el = React.createElement;
return el(Document, null,
el(Page, { size: 'A4', style: styles.page },
// Header
el(View, { style: styles.headerSection },
el(View, { style: styles.brandLine }),
el(Text, { style: styles.header }, 'COASTAL CONTRACTING OF FL')
),
// Week range
el(View, { style: styles.weekInfo },
el(Text, null, `Week of: ${formatWeekRange(weekDays)}`)
),
// Employee name
userName
? el(View, { style: styles.employeeInfo },
el(Text, null, `Employee: ${userName}`)
)
: null,
// Status badge
status && status !== 'draft'
? el(View, {
style: {
...styles.statusBadge,
backgroundColor: statusColors.bg,
color: statusColors.text,
},
},
el(Text, {
style: { color: statusColors.text },
}, `Status: ${status.charAt(0).toUpperCase() + status.slice(1)}`)
)
: null,
// Days
...weekDays.map((day, dayIndex) => {
const dayKey = day.toISOString().split('T')[0];
const dayEntries = entriesByDate[dayKey] || [];
if (dayEntries.length === 0) return null;
const dayTotal = dayEntries.reduce(
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
0
);
return el(View, { key: dayIndex, style: styles.daySection },
// Day header
el(View, { style: styles.dayHeader },
el(View, null,
el(Text, { style: styles.dayName }, DAY_NAMES[day.getUTCDay()]),
el(Text, { style: styles.dayDate }, formatDate(day))
),
el(Text, { style: styles.dayTotal }, `${dayTotal.toFixed(1)} hours`)
),
// Entries
...dayEntries.map((entry, entryIndex) => {
const isLast = entryIndex === dayEntries.length - 1;
const isAlt = entryIndex % 2 === 1;
const rowStyle = [
styles.entryRow,
isLast ? styles.entryRowLast : null,
isAlt ? styles.entryRowAlternate : null,
].filter(Boolean);
return el(View, { key: entryIndex, style: rowStyle },
el(View, { style: [styles.entryCell, styles.homeownerCell] },
el(Text, null, entry.homeownerName || entry.homeowner || '-')
),
el(View, { style: [styles.entryCell, styles.hoursCell] },
el(Text, null, String(entry.hoursWorked || '0'))
),
el(View, { style: [styles.entryCell, styles.workDescCell] },
el(Text, null, entry.workDescription || '-')
)
);
})
);
}).filter(Boolean),
// Summary
el(View, { style: styles.summarySection },
el(Text, { style: styles.summaryText }, 'Weekly Total'),
el(Text, { style: styles.totalHours }, `${totalHours.toFixed(1)} Hours`)
),
// Footer
el(Text, { style: styles.footer },
`Generated on ${new Date().toLocaleDateString()} • Coastal Contracting of FL`
)
)
);
}
/**
* Generate a timesheet PDF buffer
* @param {Object} data - { userName, weekStart, entries, status }
* @returns {Promise<Buffer>}
*/
async function generateTimesheetPDF(data) {
const doc = buildTimesheetDocument(data);
const buffer = await renderToBuffer(doc);
return buffer;
}
/**
* Build a filename for the PDF
*/
function buildPdfFilename(userName, weekStart) {
const safeName = (userName || 'timesheet')
.replace(/[^a-zA-Z0-9]/g, '_')
.replace(/_+/g, '_')
.toLowerCase();
return `timesheet_${safeName}_${weekStart}.pdf`;
}
module.exports = {
generateTimesheetPDF,
buildPdfFilename,
};