Compare commits

..
3 Commits
Author SHA1 Message Date
ricky b72c82fb2b Dynamically update the link with estimate id 2026-08-17 21:03:37 -04:00
ricky b901e64149 added new issue link to actions 2026-08-17 20:40:42 -04:00
ricky 7abb1aa06c fixed customer view to show attachments 2026-08-17 20:22:23 -04:00
4 changed files with 111 additions and 28 deletions
+65 -7
View File
@@ -1,15 +1,73 @@
<%= call_hook :customer_actions_top, { customer: @customer } %> <%= call_hook :customer_actions_top, { customer: @customer } %>
<%= link_to t(:label_create_estimate), "https://qbo.intuit.com/app/estimate?nameId=#{@customer.id}", target: :_blank %> <p>
<%= link_to t(:label_new_issue), new_issue_path(issue: { customer_id: @customer.id }), id: "dynamic-new-issue-link", target: :_blank %>
</p>
<br/> <p>
<br/> <%= link_to t(:label_create_estimate), "https://qbo.intuit.com/app/estimate?nameId=#{@customer.id}", target: :_blank %>
</p>
<%= link_to t(:label_create_payment), "https://qbo.intuit.com/app/recvpayment?nameId=#{@customer.id}", target: :_blank %> <p>
<%= link_to t(:label_create_payment), "https://qbo.intuit.com/app/recvpayment?nameId=#{@customer.id}", target: :_blank %>
<br/> </p>
<br/>
<%= call_hook :customer_actions_bottom, { customer: @customer } %> <%= call_hook :customer_actions_bottom, { customer: @customer } %>
<%= button_to t(:label_edit_customer), edit_customer_path(@customer), method: :get%> <%= button_to t(:label_edit_customer), edit_customer_path(@customer), method: :get%>
<script>
function handleSingleSelect(event, className) {
if (event.target.checked) {
// Uncheck all other checkboxes of the same type
document.querySelectorAll('.' + className).forEach(cb => {
if (cb !== event.target) {
cb.checked = false;
}
});
}
updateLink();
}
// Bind single-select behavior to checkboxes once the DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.estimate-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => handleSingleSelect(e, 'estimate-checkbox'));
});
document.querySelectorAll('.vehicle-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => handleSingleSelect(e, 'vehicle-checkbox'));
});
});
function updateLink() {
const link = document.getElementById('dynamic-new-issue-link');
if (!link) return;
// Cache the pristine base URL on first run
if (!link.dataset.baseUrl) {
link.dataset.baseUrl = link.getAttribute('href');
}
// Build base URL object
let url = new URL(link.dataset.baseUrl, window.location.origin);
// 1. Handle Single Estimate
const checkedEstimate = document.querySelector('.estimate-checkbox:checked');
if (checkedEstimate) {
url.searchParams.set('issue[estimate_id]', checkedEstimate.value);
} else {
url.searchParams.delete('issue[estimate_id]');
}
// 2. Handle Single Vehicle
const checkedVehicle = document.querySelector('.vehicle-checkbox:checked');
if (checkedVehicle) {
url.searchParams.set('issue[vehicle_id]', checkedVehicle.value);
} else {
url.searchParams.delete('issue[vehicle_id]');
}
// Update the link's href attribute
link.setAttribute('href', url.toString());
}
</script>
+1 -1
View File
@@ -2,7 +2,7 @@
<% estimates.sort.reverse.each do |estimate| %> <% estimates.sort.reverse.each do |estimate| %>
<div class="row"> <div class="row">
<%= check_box_tag "estimate_ids[]", estimate.id, false, onchange: "updateLink()", data: { url: estimate_path(estimate), text: "Estimate ##{estimate.to_s}" }, class: "estimate-checkbox appointment" %> <%= check_box_tag "estimate_ids[]", estimate.id, false, onchange: "updateLink()", data: { text: "Estimate ##{estimate.doc_number}" }, class: "estimate-checkbox appointment" %>
<b><%= link_to "##{estimate.doc_number}", estimate_path(estimate), target: :_blank %></b> <%= estimate.txn_date %> <b><%= link_to "##{estimate.doc_number}", estimate_path(estimate), target: :_blank %></b> <%= estimate.txn_date %>
</div> </div>
<% end %> <% end %>
+1
View File
@@ -65,6 +65,7 @@ en:
label_model: "Model" label_model: "Model"
label_name: "Name" label_name: "Name"
label_new_customer: "New Customer" label_new_customer: "New Customer"
label_new_issue: "New Issue"
label_qbo_never_synced: "Never Synced" label_qbo_never_synced: "Never Synced"
label_no_customers: "There are no customers matching the search term(s)." label_no_customers: "There are no customers matching the search term(s)."
label_no_estimates: "No Estimates" label_no_estimates: "No Estimates"
@@ -11,27 +11,51 @@
module RedmineQbo module RedmineQbo
module Patches module Patches
module AttachmentsControllerPatch module AttachmentsControllerPatch
module Helper
# Check if login is globally required to access the application
def check_if_login_required
# Return true if the user is already logged in
return true if User.current.logged?
# Pull up the attachment and verify if we have a valid token for the issue
attachment = Attachment.find_by(id: params[:id])
return require_login if attachment.nil?
token = CustomerToken.where("token = ? AND expires_at > ?", session[:token], Time.current).first
return true if token&.issue_id == attachment.container_id
# Default to requiring login if all else fails
require_login if Setting.login_required?
end
end
def self.apply def self.apply
AttachmentsController.class_eval do AttachmentsController.class_eval do
helper Helper # 1. PREPEND: Must run before ANY of Redmine's ApplicationController filters
prepend_before_action :set_customer_token_thread
# 2. Skip global login redirects if the user holds a valid token for this file
skip_before_action :check_if_login_required, if: :valid_customer_token?
skip_before_action :check_project_privacy, raise: false, if: :valid_customer_token?
# Note: We do NOT need to skip :read_authorize anymore.
# Because we patched Attachment#visible?, read_authorize will pass naturally!
private
def set_customer_token_thread
if session[:token].present?
Thread.current[:customer_token] = CustomerToken.active.find_by(token: session[:token])
end
end
def valid_customer_token?
token = Thread.current[:customer_token]
return false unless token
# Handle "Download All" zip requests (object_type=issues, object_id=ID)
if params[:action] == 'download_all'
return params[:object_type] == 'issues' && params[:object_id].to_i == token.issue_id
end
# Handle normal single attachment requests (show, download, thumbnail)
attachment = Attachment.find_by(id: params[:id])
return false unless attachment
# Allow if attachment belongs directly to the Issue
if attachment.container_type == 'Issue' && attachment.container_id == token.issue_id
return true
end
# Allow if attachment belongs to a Journal (comment) on the Issue
if attachment.container_type == 'Journal' && attachment.container.journalized_type == 'Issue' && attachment.container.journalized_id == token.issue_id
return true
end
false
end
end end
end end
end end