Smart Work Shift Planner

Smart Work Shift Planner

Manage Team Members

    Manage Shift Templates

    Existing Templates:

      Copy Schedule

      Weekly Hours Summary

      Team MemberTotal Hours Scheduled

      ${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 })();
      Scroll to Top