mirror of
https://github.com/rickbarrette/redmine_qbo_calendar.git
synced 2026-10-03 20:40:43 -04:00
313 lines
12 KiB
ERB
313 lines
12 KiB
ERB
<%
|
|
# Check if toggles are active via query params
|
|
only_appointments = params[:only_appointments] == '1'
|
|
hide_past_weeks = params[:hide_past_weeks] == '1'
|
|
|
|
# Get Redmine's configured non-working week days (e.g., ["6", "7"] for Sat/Sun)
|
|
non_working_days = Setting.non_working_week_days || []
|
|
|
|
# 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 non-working days
|
|
return false if non_working_days.include?(target_date.cwday.to_s)
|
|
|
|
duration = [appointment.duration.to_i, 1].max
|
|
date_cursor = start_date
|
|
working_days_count = 0
|
|
|
|
while date_cursor <= target_date
|
|
unless non_working_days.include?(date_cursor.cwday.to_s)
|
|
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;">
|
|
<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)
|
|
|
|
# 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.respond_to?(:estimated_hours) && event.estimated_hours.present?
|
|
event.estimated_hours.to_f
|
|
else
|
|
event.duration.to_f
|
|
end
|
|
else
|
|
0.0
|
|
end
|
|
end
|
|
%>
|
|
|
|
<li class="<%= calendar.day_css_classes(day) %> calbody"<%= ' 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 -->
|
|
<span class="day-num-center" style="display: inline-flex; align-items: center; justify-self: center;">
|
|
<% if day >= User.current.today && !non_working_days.include?(day.cwday.to_s) %>
|
|
<%= 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>
|
|
|
|
<!-- 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>
|
|
|
|
<% 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 %>
|
|
|
|
<script>
|
|
(function() {
|
|
const STORAGE_KEY_ONLY_APPTS = 'redmine_cal_only_appointments';
|
|
const STORAGE_KEY_HIDE_PAST = 'redmine_cal_hide_past_weeks';
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
let needsRedirect = false;
|
|
|
|
// Sync "Show Customer Appointments Only" with localStorage if not present in URL
|
|
if (!urlParams.has('only_appointments') && localStorage.getItem(STORAGE_KEY_ONLY_APPTS) === '1') {
|
|
urlParams.set('only_appointments', '1');
|
|
needsRedirect = true;
|
|
}
|
|
|
|
// Sync "Hide Past Weeks" with localStorage if not present in URL
|
|
if (!urlParams.has('hide_past_weeks') && localStorage.getItem(STORAGE_KEY_HIDE_PAST) === '1') {
|
|
urlParams.set('hide_past_weeks', '1');
|
|
needsRedirect = true;
|
|
}
|
|
|
|
if (needsRedirect) {
|
|
window.location.search = urlParams.toString();
|
|
return;
|
|
}
|
|
|
|
// Automatically scroll to today if present and no specific hash anchor is in the URL
|
|
if (!window.location.hash) {
|
|
const todayElement = document.getElementById('today');
|
|
if (todayElement) {
|
|
todayElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}
|
|
});
|
|
|
|
window.toggleOnlyAppointments = function(checked) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (checked) {
|
|
urlParams.set('only_appointments', '1');
|
|
localStorage.setItem(STORAGE_KEY_ONLY_APPTS, '1');
|
|
} else {
|
|
urlParams.delete('only_appointments');
|
|
localStorage.setItem(STORAGE_KEY_ONLY_APPTS, '0');
|
|
}
|
|
window.location.search = urlParams.toString();
|
|
};
|
|
|
|
window.toggleHidePastWeeks = function(checked) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (checked) {
|
|
urlParams.set('hide_past_weeks', '1');
|
|
localStorage.setItem(STORAGE_KEY_HIDE_PAST, '1');
|
|
} else {
|
|
urlParams.delete('hide_past_weeks');
|
|
localStorage.setItem(STORAGE_KEY_HIDE_PAST, '0');
|
|
}
|
|
window.location.search = urlParams.toString();
|
|
};
|
|
})();
|
|
</script> |