mirror of
https://github.com/rickbarrette/redmine_qbo_calendar.git
synced 2026-10-03 20:40:43 -04:00
Updates the calendar view to distribute multi-day appointment hours across working days, capping each day at 8 hours. This prevents the total hours from being added to every day and ensures appointments with fewer hours than their maximum duration don't artificially inflate daily labor totals.
334 lines
14 KiB
ERB
334 lines
14 KiB
ERB
<%= javascript_include_tag("calendar", plugin: :redmine_qbo_calendar) %>
|
|
|
|
<%
|
|
# Check if toggles are active via query params
|
|
only_appointments = params[:only_appointments] == '1'
|
|
hide_past_weeks = params[:hide_past_weeks] == '1'
|
|
|
|
# Current date context
|
|
today = User.current.today
|
|
is_current_month = (calendar.instance_variable_get(:@month) == today.month && calendar.instance_variable_get(:@year) == today.year)
|
|
|
|
# Get Redmine's configured non-working week days (e.g., ["6", "7"] for Sat/Sun)
|
|
non_working_days = Setting.non_working_week_days || []
|
|
|
|
all_holidays = Holiday.all
|
|
|
|
# Safely collect all unique CustomerAppointments across the entire calendar date range
|
|
all_appointments = (calendar.startdt..calendar.enddt).flat_map { |date|
|
|
calendar.events_on(date)
|
|
}.select { |e| e.is_a?(CustomerAppointment) }.uniq
|
|
|
|
# Lambda to determine if an appointment should render on a specific calendar day
|
|
appointment_occurs_on = lambda do |appointment, current_day|
|
|
app_datetime = appointment.respond_to?(:date) && appointment.date.present? ? appointment.date : appointment.start_date
|
|
return false if app_datetime.nil?
|
|
|
|
start_date = app_datetime.to_date
|
|
target_date = current_day.to_date
|
|
|
|
return false if target_date < start_date
|
|
|
|
# Skip rendering on Redmine standard non-working days
|
|
return false if non_working_days.include?(target_date.cwday.to_s)
|
|
|
|
# Skip rendering on custom non-working days (Holidays)
|
|
return false if all_holidays.any? { |h| h.occurs_on?(target_date) }
|
|
|
|
duration = [appointment.duration.to_i, 1].max
|
|
date_cursor = start_date
|
|
working_days_count = 0
|
|
|
|
while date_cursor <= target_date
|
|
is_standard_weekend = non_working_days.include?(date_cursor.cwday.to_s)
|
|
is_custom_holiday = all_holidays.any? { |h| h.occurs_on?(date_cursor) }
|
|
|
|
# Only increment duration count if it's a standard working day AND not a holiday
|
|
unless is_standard_weekend || is_custom_holiday
|
|
working_days_count += 1
|
|
end
|
|
|
|
if date_cursor == target_date
|
|
return working_days_count <= duration
|
|
end
|
|
|
|
date_cursor += 1 # Pure Ruby Date increment stays a Date object
|
|
end
|
|
|
|
false
|
|
end
|
|
%>
|
|
|
|
<%# Calendar Status Legend Key %>
|
|
<%
|
|
# Collect status keys from CustomerAppointment enum
|
|
statuses = CustomerAppointment::STATUSES
|
|
%>
|
|
|
|
<% if statuses.any? %>
|
|
<div class="calendar-status-legend" style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0;">
|
|
<span class="legend-label" style="font-weight: 600; font-size: 0.85em; color: #666;">
|
|
<%= l(:label_legend, default: 'Legend') %>:
|
|
</span>
|
|
|
|
<% statuses.each do |status_key| %>
|
|
<span class="status-key-badge status-<%= status_key.parameterize %>"
|
|
style="padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: 600; text-transform: capitalize;">
|
|
<%= status_key.humanize %>
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Toggle Bar -->
|
|
<div class="calendar-filter-toolbar" style="margin-bottom: 12px; display: flex; align-items: center; justify-content: flex-end; gap: 16px;">
|
|
<% if is_current_month %>
|
|
<button type="button"
|
|
onclick="scrollToToday()"
|
|
class="button-small"
|
|
style="display: inline-flex; align-items: center; gap: 5px; cursor: pointer; font-weight: 600; font-size: 0.9em; padding: 4px 10px; border-radius: 3px;"
|
|
title="<%= l(:label_today) %>">
|
|
<i class="fa fa-calendar-check-o" aria-hidden="true"></i>
|
|
<%= l(:label_today) %>
|
|
</button>
|
|
<% else %>
|
|
<%= link_to url_for(request.query_parameters.merge(year: today.year, month: today.month)),
|
|
class: 'button-small',
|
|
style: 'display: inline-flex; align-items: center; gap: 5px; text-decoration: none; font-weight: 600; font-size: 0.9em; padding: 4px 10px; border-radius: 3px;',
|
|
title: l(:label_today) do %>
|
|
<i class="fa fa-calendar-check-o" aria-hidden="true"></i>
|
|
<%= l(:label_today,) %>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<label for="toggle-hide-past-weeks" style="cursor: pointer; font-weight: 600; font-size: 0.9em; color: #444; display: inline-flex; align-items: center; gap: 6px;">
|
|
<%= check_box_tag 'hide_past_weeks',
|
|
'1',
|
|
hide_past_weeks,
|
|
id: 'toggle-hide-past-weeks',
|
|
onchange: 'toggleHidePastWeeks(this.checked)' %>
|
|
<%= l(:label_hide_past_weeks, default: 'Hide Past Weeks') %>
|
|
</label>
|
|
|
|
<label for="toggle-only-appointments" style="cursor: pointer; font-weight: 600; font-size: 0.9em; color: #444; display: inline-flex; align-items: center; gap: 6px;">
|
|
<%= check_box_tag 'only_appointments',
|
|
'1',
|
|
only_appointments,
|
|
id: 'toggle-only-appointments',
|
|
onchange: 'toggleOnlyAppointments(this.checked)' %>
|
|
<%= l(:label_only_customer_appointments, default: 'Show Customer Appointments Only') %>
|
|
</label>
|
|
</div>
|
|
|
|
<%= form_tag({}, data: { cm_url: issues_context_menu_path }) do %>
|
|
<%= hidden_field_tag 'back_url', url_for(params: request.query_parameters), id: nil %>
|
|
|
|
<ul class="cal">
|
|
<li scope="col" title="<%= l(:label_week) %>" class="calhead week-number"></li>
|
|
|
|
<% 7.times do |i| %>
|
|
<li scope="col" class="calhead">
|
|
<%= day_name((calendar.first_wday + i) % 7) %>
|
|
</li>
|
|
<% end %>
|
|
|
|
<% calendar.format_month.each_slice(7) do |week| %>
|
|
<%# Skip week if "hide past weeks" is active and every day in the week is in the past %>
|
|
<% next if hide_past_weeks && week.last < User.current.today %>
|
|
|
|
<li class="week-number">
|
|
<span class="label-week"><%= l(:label_week) %></span>
|
|
<%= calendar.week_number(week.first) %>
|
|
</li>
|
|
|
|
<% week.each do |day| %>
|
|
|
|
<%
|
|
is_today = (day == User.current.today)
|
|
|
|
# Find the specific holiday for this day (if any)
|
|
current_holiday = all_holidays.find { |h| h.occurs_on?(day) }
|
|
is_custom_holiday = current_holiday.present?
|
|
is_standard_weekend = non_working_days.include?(day.cwday.to_s)
|
|
|
|
# Filter out standard events if "only appointments" toggle is active
|
|
standard_events = only_appointments ? [] : calendar.events_on(day).reject { |e| e.is_a?(CustomerAppointment) }
|
|
|
|
# Appointments matching duration on this working day
|
|
matching_appointments = all_appointments.select { |app| appointment_occurs_on.call(app, day) }
|
|
|
|
# Sort events chronologically by time of day
|
|
day_events = (standard_events + matching_appointments).sort_by do |event|
|
|
if event.is_a?(CustomerAppointment) && event.date.present?
|
|
event.date.strftime('%H:%M:%S')
|
|
elsif event.respond_to?(:start_date) && event.start_date.present?
|
|
event.start_date.to_time.strftime('%H:%M:%S')
|
|
else
|
|
'00:00:00'
|
|
end
|
|
end
|
|
|
|
# Sum estimated hours for Issues and CustomerAppointments
|
|
total_hours = day_events.sum do |event|
|
|
if event.is_a?(Issue)
|
|
event.estimated_hours.to_f
|
|
elsif event.is_a?(CustomerAppointment)
|
|
if event.duration.to_i > 1
|
|
# Get the total hours (fallback to duration * 8 if estimated_hours is blank)
|
|
app_total_hours = if event.respond_to?(:estimated_hours) && event.estimated_hours.present?
|
|
event.estimated_hours.to_f
|
|
else
|
|
event.duration.to_f * 8.0
|
|
end
|
|
|
|
start_date = (event.respond_to?(:date) && event.date.present? ? event.date : event.start_date).to_date
|
|
|
|
# Count how many working days occurred before the current calendar 'day'
|
|
working_day_index = 0
|
|
date_cursor = start_date
|
|
|
|
while date_cursor < day
|
|
is_weekend = non_working_days.include?(date_cursor.cwday.to_s)
|
|
is_holiday = all_holidays.any? { |h| h.occurs_on?(date_cursor) }
|
|
working_day_index += 1 unless is_weekend || is_holiday
|
|
date_cursor += 1
|
|
end
|
|
|
|
# Subtract previous days' hours (8h/day) from the total
|
|
hours_already_allocated = working_day_index * 8.0
|
|
remaining_hours = app_total_hours - hours_already_allocated
|
|
|
|
# Allocate up to 8 hours for today, but don't go below 0
|
|
[[remaining_hours, 8.0].min, 0.0].max
|
|
elsif event.respond_to?(:estimated_hours) && event.estimated_hours.present?
|
|
event.estimated_hours.to_f
|
|
else
|
|
event.duration.to_f
|
|
end
|
|
else
|
|
0.0
|
|
end
|
|
end
|
|
%>
|
|
|
|
<%# Added 'week-end' class and background color styling for custom holidays to mimic weekends %>
|
|
<li class="<%= calendar.day_css_classes(day) %> calbody <%= 'week-end' if is_custom_holiday %>" style="<%= 'background-color: #f6f6f6;' if is_custom_holiday %>"<%= ' id="today"'.html_safe if is_today %>>
|
|
|
|
<p class="day-num" style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 4px;">
|
|
<!-- Left: Day number and abbreviated weekday -->
|
|
<span class="day-num-left" style="display: inline-flex; align-items: center; justify-self: start; gap: 3px;">
|
|
<a name="<%= day.to_s %>" <%= 'id="today-anchor"'.html_safe if is_today %> style="text-decoration: none; color: inherit;">
|
|
<span class="day-value" style="font-weight: bold;"><%= day.day %></span>
|
|
</a>
|
|
<span class="abbr-day" style="font-size: 0.85em; color: #777;">(<%= abbr_day_name(day.cwday) %>)</span>
|
|
</span>
|
|
|
|
<!-- Center: New Appointment Action -->
|
|
<% if User.current.allowed_to?(:add_customer_appointments, nil, global: true) %>
|
|
<span class="day-num-center" style="display: inline-flex; align-items: center; justify-self: center;">
|
|
<%# Only show ADD button if it's in the future, NOT a weekend, NOT a holiday, AND user has permission %>
|
|
<% if day >= User.current.today && !is_standard_weekend && !is_custom_holiday && User.current.allowed_to?(:add_customer_appointments, nil, global: true) %>
|
|
<%= link_to sprite_icon('add'),
|
|
new_customer_appointment_path(start_date: day),
|
|
class: 'icon-only icon-add',
|
|
title: l(:label_appointment_new, default: 'New Appointment') %>
|
|
<% end %>
|
|
</span>
|
|
<% end %>
|
|
|
|
<!-- Right: Total Hours -->
|
|
<span class="day-num-right" style="display: inline-flex; align-items: center; justify-self: end;">
|
|
<% if total_hours > 0 %>
|
|
<span class="hours-total" style="font-weight: bold; color: #555;" title="<%= l(:label_estimated_hours, default: 'Estimated hours') %>">
|
|
<%= total_hours.round(1) %>h
|
|
</span>
|
|
<% end %>
|
|
</span>
|
|
</p>
|
|
|
|
<%# Render Holiday Name Badge if it's a holiday %>
|
|
<% if is_custom_holiday %>
|
|
<div class="holiday-badge" style="background-color: #f2dede; color: #a94442; padding: 3px 6px; border-radius: 3px; font-size: 0.8em; font-weight: bold; margin-bottom: 4px; border: 1px solid #ebccd1; display: flex; align-items: center; gap: 4px;">
|
|
<span class="fa-stack fa-lg">
|
|
<i class="fas fa-calendar fa-stack-1x"></i>
|
|
<i class="fas fa-slash fa-stack-2x" style="color:Tomato"></i>
|
|
</span>
|
|
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"><%= current_holiday.name %></span>
|
|
</div>
|
|
<% end %>
|
|
|
|
<% day_events.each do |event| %>
|
|
|
|
<% if event.is_a?(Issue) %>
|
|
|
|
<% starting = (day == event.start_date) %>
|
|
<% ending = (day == event.due_date) %>
|
|
|
|
<%= tag.div class: [
|
|
event.css_classes,
|
|
'tooltip',
|
|
'hascontextmenu',
|
|
('starting' if starting),
|
|
('ending' if ending)
|
|
].compact do %>
|
|
|
|
<%= "#{event.project} -" unless @project && @project == event.project %>
|
|
|
|
<%= sprite_icon('bullet-go', rtl: true) if starting && !ending %>
|
|
<%= sprite_icon('bullet-end', rtl: true) if ending && !starting %>
|
|
<%= sprite_icon('bullet-go-end') if starting && ending %>
|
|
|
|
<%= link_to_issue(event, truncate: 30) %>
|
|
|
|
<span class="tip">
|
|
<%= render_issue_tooltip(event) %>
|
|
</span>
|
|
|
|
<%= check_box_tag 'ids[]',
|
|
event.id,
|
|
false,
|
|
style: 'display:none;',
|
|
id: nil %>
|
|
|
|
<% end %>
|
|
|
|
<% elsif event.is_a?(Version) %>
|
|
|
|
<span class="icon icon-package">
|
|
|
|
<%= sprite_icon 'package' %>
|
|
|
|
<%= "#{event.project} -" unless @project && @project == event.project %>
|
|
|
|
<%= link_to_version(event) %>
|
|
|
|
</span>
|
|
|
|
<% elsif event.is_a?(CustomerAppointment) %>
|
|
|
|
<%= render partial: 'customer_appointments/calendar_event',
|
|
locals: {
|
|
appointment: event,
|
|
day: day,
|
|
project: @project
|
|
} %>
|
|
|
|
<% else %>
|
|
|
|
<% Rails.logger.warn("Unknown calendar event: #{event.class}") %>
|
|
|
|
<% end %>
|
|
|
|
<% end %>
|
|
|
|
</li>
|
|
|
|
<% end %>
|
|
|
|
<% end %>
|
|
|
|
</ul>
|
|
|
|
<% end %>
|
|
|
|
<%= context_menu %> |