mirror of
https://github.com/rickbarrette/redmine_qbo.git
synced 2026-08-19 12:50:43 -04:00
Compare commits
4
Commits
dfd3a641db
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae82f1423 | ||
|
|
b72c82fb2b | ||
|
|
b901e64149 | ||
|
|
7abb1aa06c |
@@ -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>
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
<% 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 %>
|
||||||
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<p><%=t(:label_no_estimates)%>.</p>
|
<p><%=t(:label_no_estimates)%>.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ module RedmineQbo
|
|||||||
edit_section_links: false,
|
edit_section_links: false,
|
||||||
headings: false,
|
headings: false,
|
||||||
inline_attachments: false)
|
inline_attachments: false)
|
||||||
|
|
||||||
|
# Apply the fix here
|
||||||
|
text = sanitize_html_for_pdf(text)
|
||||||
|
|
||||||
pdf.RDMwriteFormattedCell(190, 5, '', '', text, issue.attachments, "LRB")
|
pdf.RDMwriteFormattedCell(190, 5, '', '', text, issue.attachments, "LRB")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -275,6 +279,10 @@ module RedmineQbo
|
|||||||
pdf.ln unless journal.details.empty?
|
pdf.ln unless journal.details.empty?
|
||||||
pdf.SetFontStyle('', 8)
|
pdf.SetFontStyle('', 8)
|
||||||
text = textilizable(journal, :notes, only_path: false, edit_section_links: false, headings: false, inline_attachments: false)
|
text = textilizable(journal, :notes, only_path: false, edit_section_links: false, headings: false, inline_attachments: false)
|
||||||
|
|
||||||
|
# Apply the fix here
|
||||||
|
text = sanitize_html_for_pdf(text)
|
||||||
|
|
||||||
pdf.RDMwriteFormattedCell(190, 5, '', '', text, issue.attachments, "")
|
pdf.RDMwriteFormattedCell(190, 5, '', '', text, issue.attachments, "")
|
||||||
end
|
end
|
||||||
pdf.ln
|
pdf.ln
|
||||||
@@ -309,6 +317,40 @@ module RedmineQbo
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# NEW HELPER: Aggressively cleans up HTML so RBPDF doesn't crash on tables
|
||||||
|
def sanitize_html_for_pdf(text)
|
||||||
|
clean_text = text.to_s.dup
|
||||||
|
|
||||||
|
# 1. RBPDF layout engine hates div wrappers. Strip all opening and closing divs.
|
||||||
|
clean_text.gsub!(/<\/?div[^>]*>/i, '')
|
||||||
|
|
||||||
|
# 2. Rebuild tables into a completely flat, pure HTML structure that TCPDF supports
|
||||||
|
clean_text.gsub!(/<table[^>]*>.*?<\/table>/mi) do |match|
|
||||||
|
table_html = match.dup
|
||||||
|
|
||||||
|
# Strip thead and tbody tags completely
|
||||||
|
table_html.gsub!(/<\/?thead[^>]*>/i, '')
|
||||||
|
table_html.gsub!(/<\/?tbody[^>]*>/i, '')
|
||||||
|
|
||||||
|
# TCPDF cell width calculations crash on <th> tags. Convert them to <td> + bold.
|
||||||
|
table_html.gsub!(/<th([^>]*)>/i, '<td\1><strong>')
|
||||||
|
table_html.gsub!(/<\/th>/i, '</strong></td>')
|
||||||
|
|
||||||
|
# Remove all newlines and spaces between tags to prevent stray text nodes crashing the parser
|
||||||
|
table_html.gsub!(/>\s+</m, '><')
|
||||||
|
table_html.gsub!(/\r?\n/, '')
|
||||||
|
|
||||||
|
# Inject a standardized <table> tag with borders so the table actually renders visibly
|
||||||
|
table_html.sub!(/<table[^>]*>/i, '<table border="1" cellpadding="4" style="border-collapse: collapse;">')
|
||||||
|
|
||||||
|
table_html
|
||||||
|
end
|
||||||
|
|
||||||
|
clean_text
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Reference in New Issue
Block a user