${formatTimeSimple(shift.startTime)} - ${formatTimeSimple(endTime)} (${formatDuration(shift.durationMinutes)})
${memberName}
${shift.role ? `
${shift.role}
` : ''}
`;
dayUL.appendChild(shiftLi);
});
}
// Add listeners after grid is built
scheduleGridDiv.querySelectorAll('.add-shift-to-day-btn').forEach(btn => btn.addEventListener('click', (e)=> openShiftModal(e.target.dataset.date)));
scheduleGridDiv.querySelectorAll('.edit-shift-instance-btn').forEach(btn => btn.addEventListener('click', (e)=> openShiftModal(e.target.dataset.date, e.target.dataset.id)));
scheduleGridDiv.querySelectorAll('.delete-shift-instance-btn').forEach(btn => btn.addEventListener('click', (e)=> deleteShiftInstance(e.target.dataset.id)));
updateMemberHoursSummary(week.start, week.end);
}
saveShiftInstanceBtn.addEventListener('click', () => {
const date = shiftModalDateInput.value;
const memberId = shiftMemberSelect.value; // V1: single member
const startTime = shiftStartTimeInput.value;
const durationHours = parseFloat(shiftDurationHoursInput.value);
const roleNotes = shiftRoleNotesInput.value.trim() || null;
if (!memberId || !startTime || isNaN(durationHours) || durationHours <=0) {
alert('Member, Start Time, and valid Duration are required.'); return;
}
const durationMinutes = Math.round(durationHours * 60);
const newShiftStart = new Date(`${date}T${startTime}`).getTime();
const newShiftEnd = newShiftStart + durationMinutes * 60000;
// Overlap Check
const memberShiftsOnDate = scheduledShifts.filter(s => s.date === date && s.memberIds.includes(memberId) && s.id !== currentEditingShiftInstanceId);
for(const existingShift of memberShiftsOnDate) {
const existingStart = new Date(`${existingShift.date}T${existingShift.startTime}`).getTime();
const existingEnd = existingStart + existingShift.durationMinutes * 60000;
if (newShiftStart < existingEnd && newShiftEnd > existingStart) {
alert(`Overlap detected with another shift for ${teamMembers.find(m=>m.id===memberId)?.name || 'this member'} on this day.`);
return;
}
}
const shiftData = { date, startTime, durationMinutes, memberIds: [memberId], role: roleNotes };
if (currentEditingShiftInstanceId) {
const index = scheduledShifts.findIndex(s => s.id === currentEditingShiftInstanceId);
scheduledShifts[index] = { ...scheduledShifts[index], ...shiftData };
} else {
scheduledShifts.push({ id: generateId(), ...shiftData });
}
saveScheduledShifts();
renderWeeklySchedule(); // Will re-render the specific day column
shiftModal.style.display = 'none';
});
function deleteShiftInstance(shiftId) {
if (confirm('Delete this shift?')) {
scheduledShifts = scheduledShifts.filter(s => s.id !== shiftId);
saveScheduledShifts();
renderWeeklySchedule();
}
}
function updateMemberHoursSummary(weekStart, weekEnd) {
memberHoursTbody.innerHTML = '';
if (teamMembers.length === 0) {
memberHoursTbody.innerHTML = '
| No team members defined. |
'; return;
}
teamMembers.forEach(member => {
let totalMinutes = 0;
scheduledShifts.forEach(shift => {
const shiftDate = new Date(shift.date + "T00:00:00");
if (shift.memberIds.includes(member.id) && shiftDate >= weekStart && shiftDate <= weekEnd) {
totalMinutes += shift.durationMinutes;
}
});
const row = memberHoursTbody.insertRow();
row.insertCell().textContent = member.name;
row.insertCell().textContent = formatDuration(totalMinutes) + ` (${(totalMinutes/60).toFixed(1)}h)`;
});
}
// --- Copy Logic ---
copyDayBtn.addEventListener('click', () => {
const fromDateStr = copyFromDaySelect.value;
const toDateStr = copyToDaySelect.value;
if (!fromDateStr || !toDateStr || fromDateStr === toDateStr) {
alert("Please select different 'from' and 'to' days for copying."); return;
}
if (!confirm(`Copy all shifts from ${formatDate(new Date(fromDateStr+"T00:00:00"))} to ${formatDate(new Date(toDateStr+"T00:00:00"))}? This will replace existing shifts on the destination day.`)) return;
// Remove existing shifts from 'to' day
scheduledShifts = scheduledShifts.filter(s => s.date !== toDateStr);
const shiftsToCopy = scheduledShifts.filter(s => s.date === fromDateStr);
shiftsToCopy.forEach(shift => {
const newShift = {...shift, id: generateId(), date: toDateStr };
scheduledShifts.push(newShift);
});
saveScheduledShifts();
renderWeeklySchedule();
alert("Day's schedule copied.");
});
copyWeekBtn.addEventListener('click', () => {
const currentWeekStart = displayedWeekStartDate;
const nextWeekStart = addDays(currentWeekStart, 7);
if (!confirm(`Copy all shifts from this week (${formatDate(currentWeekStart)}) to next week (${formatDate(nextWeekStart)})? This will replace any existing shifts in the next week.`)) return;
// Remove all shifts from the target next week first
for (let i=0; i<7; i++) {
const targetDayStr = formatDateYYYYMMDD(addDays(nextWeekStart, i));
scheduledShifts = scheduledShifts.filter(s => s.date !== targetDayStr);
}
// Copy shifts from current week to next week
for (let i=0; i<7; i++) {
const sourceDayStr = formatDateYYYYMMDD(addDays(currentWeekStart, i));
const targetDayStr = formatDateYYYYMMDD(addDays(nextWeekStart, i));
const shiftsToCopy = scheduledShifts.filter(s => s.date === sourceDayStr);
shiftsToCopy.forEach(shift => {
const newShift = {...shift, id: generateId(), date: targetDayStr };
scheduledShifts.push(newShift);
});
}
saveScheduledShifts();
// Navigate to next week and render
displayedWeekStartDate = nextWeekStart;
weekPicker.value = formatDateYYYYMMDD(displayedWeekStartDate);
renderWeeklySchedule();
alert("Week's schedule copied to next week.");
});
// --- PDF Download ---
downloadSchedulePdfBtn.addEventListener('click', () => {
const weekStartPref = parseInt(scheduleWeekStartDaySelect.value);
const week = getWeekRange(displayedWeekStartDate, weekStartPref);
const { jsPDF } = window.jspdf;
const doc = new jsPDF('p', 'pt', 'a4');
const margin = 40; let yPos = margin; const pageWidth = doc.internal.pageSize.getWidth();
doc.setFontSize(18); doc.setTextColor(varToRGB('--primary-color').r, varToRGB('--primary-color').g, varToRGB('--primary-color').b);
doc.text('Weekly Shift Plan', pageWidth / 2, yPos, { align: 'center' }); yPos += 20;
doc.setFontSize(12); doc.setTextColor(100);
doc.text(`Week: ${formatDate(week.start)} - ${formatDate(week.end)}`, pageWidth / 2, yPos, { align: 'center' }); yPos += 15;
doc.setFontSize(10); doc.text(`Generated: ${new Date().toLocaleString()}`, pageWidth / 2, yPos, { align: 'center' }); yPos += 25;
const dayNamesPdf = weekStartPref === 1
? ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
for (let i = 0; i < 7; i++) {
const dayDate = addDays(week.start, i);
const dayDateStr = formatDateYYYYMMDD(dayDate);
const shiftsForDay = scheduledShifts.filter(s => s.date === dayDateStr)
.sort((a,b) => a.startTime.localeCompare(b.startTime));
if (yPos > doc.internal.pageSize.getHeight() - 60 && shiftsForDay.length > 0) { doc.addPage(); yPos = margin; }
doc.setFontSize(12); doc.setTextColor(varToRGB('--primary-color').r, varToRGB('--primary-color').g, varToRGB('--primary-color').b);
doc.text(`${dayNamesPdf[i]}, ${formatDate(dayDate)}`, margin, yPos); yPos += 18;
if (shiftsForDay.length > 0) {
const body = shiftsForDay.map(shift => {
const memberName = teamMembers.find(m => m.id === shift.memberIds[0])?.name || 'Unassigned';
const endTime = calculateEndTime(shift.startTime, shift.durationMinutes / 60);
return [
formatTimeSimple(shift.startTime) + " - " + formatTimeSimple(endTime),
`${formatDuration(shift.durationMinutes)}`,
memberName,
shift.role || ''
];
});
doc.autoTable({
startY: yPos, head: [['Time', 'Duration', 'Member', 'Role/Notes']], body: body,
theme: 'grid', headStyles: { fillColor: varToRGB('--primary-color', true), textColor: varToRGB('--light-text', true) },
styles: { fontSize: 9, cellPadding:3 }, columnStyles: { 3: {cellWidth:'auto'}}
});
yPos = doc.lastAutoTable.finalY + 10;
} else {
doc.setFontSize(9); doc.setTextColor(150);
doc.text('No shifts scheduled for this day.', margin + 5, yPos); yPos += 12;
}
yPos += 5; // Space between days
}
// Member Hours Summary
if (yPos > doc.internal.pageSize.getHeight() - 80 && teamMembers.length > 0) { doc.addPage(); yPos = margin; }
doc.setFontSize(12); doc.setTextColor(varToRGB('--primary-color').r, varToRGB('--primary-color').g, varToRGB('--primary-color').b);
doc.text('Weekly Hours Summary:', margin, yPos); yPos += 18;
const summaryBody = [];
memberHoursTbody.querySelectorAll('tr').forEach(tr => {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.textContent);
if (cells.length === 2) summaryBody.push(cells);
});
if(summaryBody.length > 0) {
doc.autoTable({ startY:yPos, head:[['Member', 'Total Hours']], body: summaryBody, theme:'striped', headStyles:{fillColor:[200,200,200],textColor:20}, styles:{fontSize:9}});
} else { doc.text('No member hours to summarize.', margin, yPos); }
doc.save(`Weekly_Shift_Plan_${formatDateYYYYMMDD(week.start)}.pdf`);
});
function varToRGB(varName, asArray = false) {
const colorHex = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
if (!colorHex.startsWith('#')) { return asArray ? [0,0,0] : {r:0,g:0,b:0}; }
const r = parseInt(colorHex.slice(1, 3), 16); const g = parseInt(colorHex.slice(3, 5), 16); const b = parseInt(colorHex.slice(5, 7), 16);
return asArray ? [r,g,b] : {r,g,b};
}
// --- Initializations ---
scheduleWeekStartDaySelect.value = localStorage.getItem('smartShiftPlanner_weekStartPref_v1') || '1';
displayedWeekStartDate = getWeekStart(new Date(), parseInt(scheduleWeekStartDaySelect.value));
weekPicker.value = formatDateYYYYMMDD(displayedWeekStartDate);
renderTeamMembersList();
renderShiftTemplatesList();
renderWeeklySchedule(); // Initial render
updateAssigneeDropdowns(); // For shift modal
})();