Compare commits

...

14 Commits

Author SHA1 Message Date
f73973a4e1 2026.3.14 2026-03-21 10:27:33 -04:00
7cd388dbd4 Fixed webhook 2026-03-21 10:26:57 -04:00
ee2ab04206 added placeholder 2026-03-19 18:15:04 -04:00
8a8c6f5fa0 renamed issue_customer_id to customer_id 2026-03-19 18:07:21 -04:00
cc36bc16b4 use the autocomplete 2026-03-19 10:36:46 -04:00
874ec7c2dc updated plugin_config screenshot 2026-03-19 09:16:32 -04:00
f3fe38cd57 2026.3.12 2026-03-19 08:34:27 -04:00
977cbfe0e1 removed coffee-rails 2026-03-19 08:22:25 -04:00
82712f361c fixed estimate path 2026-03-19 08:07:34 -04:00
4ae7d75478 removed jquery-ui-rails 2026-03-19 07:57:00 -04:00
8fb9d74277 removced placeholder for customer field 2026-03-19 07:20:59 -04:00
b0e6236cee removed old autocomplete js 2026-03-18 21:56:52 -04:00
b367687113 Implmented custom autocomplete for customer field 2026-03-18 21:55:55 -04:00
460bcd466f Fixed routes 2026-03-18 19:11:33 -04:00
14 changed files with 145 additions and 41 deletions

View File

@@ -4,11 +4,5 @@ gem 'quickbooks-ruby'
gem 'oauth2' gem 'oauth2'
gem 'roxml' gem 'roxml'
gem 'will_paginate' gem 'will_paginate'
gem 'rails-jquery-autocomplete'
gem 'jquery-ui-rails'
gem 'rexml' gem 'rexml'
gem 'combine_pdf' gem 'combine_pdf'
group :assets do
gem 'coffee-rails'
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

After

Width:  |  Height:  |  Size: 269 KiB

View File

@@ -30,8 +30,6 @@ class CustomersController < ApplicationController
before_action :view_customer, except: [:new, :view] before_action :view_customer, except: [:new, :view]
skip_before_action :verify_authenticity_token, :check_if_login_required, only: [:view] skip_before_action :verify_authenticity_token, :check_if_login_required, only: [:view]
autocomplete :customer, :name, full: true, extra_data: [:id]
def address_to_s(address) def address_to_s(address)
return if address.nil? return if address.nil?
@@ -62,6 +60,19 @@ class CustomersController < ApplicationController
params.require(:customer).permit(:name, :email, :primary_phone, :mobile_phone, :phone_number, :notes) params.require(:customer).permit(:name, :email, :primary_phone, :mobile_phone, :phone_number, :notes)
end end
# Used for autocomplete form
def autocomplete
term = ActiveRecord::Base.sanitize_sql_like(params[:q].to_s)
items = Customer.where("name LIKE :t OR phone_number LIKE :t OR mobile_phone_number LIKE :t", t: "%#{term}%")
.order(:name)
.limit(20)
render json: items.map { |i|
{ id: i.id, name: i.name, phone_number: i.phone_number, mobile_phone_number: i.mobile_phone_number }
}
end
def create def create
@customer = Customer.new(allowed_params) @customer = Customer.new(allowed_params)
@customer.save @customer.save

View File

@@ -48,7 +48,7 @@ class WebhookProcessJob < ActiveJob::Base
Redmine::Hook.call_hook( :qbo_additional_entities ).each do |context| Redmine::Hook.call_hook( :qbo_additional_entities ).each do |context|
next unless context next unless context
Array(context).each do |entity| Array(context).each do |entity|
jobs.push(entity) entities.push(entity)
log "Added additional QBO entity #{entity}" log "Added additional QBO entity #{entity}"
end end
end end

View File

@@ -1,5 +1,5 @@
<%= form_tag(customers_path, method: "get", id: "customer-search-form") do %> <%= form_tag(customers_path, method: "get", id: "customer-search-form") do %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_customers), autocomplete: "off" %> <%= text_field_tag :search, params[:search], class: "customer-name", placeholder: t(:label_search_customers), autocomplete: "off", data: { autocomplete_url: "/customers/autocomplete" } %>
<%= submit_tag t(:label_search) %> <%= submit_tag t(:label_search) %>
<% end %> <% end %>
<%= button_to t(:label_new_customer), new_customer_path, method: :get%> <%= button_to t(:label_new_customer), new_customer_path, method: :get%>

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: estimates_path(estimate), text: "Estimate ##{estimate.to_s}" }, class: "estimate-checkbox appointment" %> <%= check_box_tag "estimate_ids[]", estimate.id, false, onchange: "updateLink()", data: { url: estimate_path(estimate), text: "Estimate ##{estimate.to_s}" }, 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 %>

View File

@@ -71,9 +71,9 @@
</div> </div>
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<%= link_to t(:label_sync_now_customers), customers_sync_path(full_sync: true), class: 'button icon icon-reload' %> <%= link_to t(:label_sync_now_customers), sync_customers_path, class: 'button icon icon-reload' %>
<%= link_to t(:label_sync_now_employees), employees_sync_path, class: 'button icon icon-reload' %> <%= link_to t(:label_sync_now_employees), employees_sync_path, class: 'button icon icon-reload' %>
<%= link_to t(:label_sync_now_invoices), invoices_sync_path(full_sync: true), class: 'button icon icon-reload' %> <%= link_to t(:label_sync_now_invoices), invoices_sync_path, class: 'button icon icon-reload' %>
<%= link_to t(:label_sync_now_estimate), estimates_sync_path, class: 'button icon icon-reload' %> <%= link_to t(:label_sync_now_estimate), estimates_sync_path, class: 'button icon icon-reload' %>
</div> </div>
</fieldset> </fieldset>

View File

@@ -1 +0,0 @@
!function(t){t.fn.railsAutocomplete=function(e){var a=function(){this.railsAutoCompleter||(this.railsAutoCompleter=new t.railsAutocomplete(this))};if(void 0!==t.fn.on){if(!e)return;return t(document).on("focus",e,a)}return this.live("focus",a)},t.railsAutocomplete=function(t){var e=t;this.init(e)},t.railsAutocomplete.options={showNoMatches:!0,noMatchesLabel:"no existing match"},t.railsAutocomplete.fn=t.railsAutocomplete.prototype={railsAutocomplete:"0.0.1"},t.railsAutocomplete.fn.extend=t.railsAutocomplete.extend=t.extend,t.railsAutocomplete.fn.extend({init:function(e){function a(t){return t.split(e.delimiter)}function i(t){return a(t).pop().replace(/^\s+/,"")}e.delimiter=t(e).attr("data-delimiter")||null,e.min_length=t(e).attr("data-min-length")||t(e).attr("min-length")||2,e.append_to=t(e).attr("data-append-to")||null,e.autoFocus=t(e).attr("data-auto-focus")||!1,t(e).autocomplete({appendTo:e.append_to,autoFocus:e.autoFocus,delay:t(e).attr("delay")||0,source:function(a,r){var n=this.element[0],o={term:i(a.term)};t(e).attr("data-autocomplete-fields")&&t.each(t.parseJSON(t(e).attr("data-autocomplete-fields")),function(e,a){o[e]=t(a).val()}),t.getJSON(t(e).attr("data-autocomplete"),o,function(){var a={};t.extend(a,t.railsAutocomplete.options),t.each(a,function(i,r){if(a.hasOwnProperty(i)){var n=t(e).attr("data-"+i);a[i]=n?n:r}}),0==arguments[0].length&&t.inArray(a.showNoMatches,[!0,"true"])>=0&&(arguments[0]=[],arguments[0][0]={id:"",label:a.noMatchesLabel}),t(arguments[0]).each(function(a,i){var r={};r[i.id]=i,t(e).data(r)}),r.apply(null,arguments),t(n).trigger("railsAutocomplete.source",arguments)})},change:function(e,a){if(t(this).is("[data-id-element]")&&""!==t(t(this).attr("data-id-element")).val()&&(t(t(this).attr("data-id-element")).val(a.item?a.item.id:"").trigger("change"),t(this).attr("data-update-elements"))){var i=t.parseJSON(t(this).attr("data-update-elements")),r=a.item?t(this).data(a.item.id.toString()):{};if(i&&""===t(i.id).val())return;for(var n in i){var o=t(i[n]);o.is(":checkbox")?null!=r[n]&&o.prop("checked",r[n]):o.val(a.item?r[n]:"").trigger("change")}}},search:function(){var t=i(this.value);return t.length<e.min_length?!1:void 0},focus:function(){return!1},select:function(i,r){if(r.item.value=r.item.value.toString(),-1!=r.item.value.toLowerCase().indexOf("no match")||-1!=r.item.value.toLowerCase().indexOf("too many results"))return t(this).trigger("railsAutocomplete.noMatch",r),!1;var n=a(this.value);if(n.pop(),n.push(r.item.value),null!=e.delimiter)n.push(""),this.value=n.join(e.delimiter);else if(this.value=n.join(""),t(this).attr("data-id-element")&&t(t(this).attr("data-id-element")).val(r.item.id).trigger("change"),t(this).attr("data-update-elements")){var o=r.item,l=-1!=r.item.value.indexOf("Create New")?!0:!1,u=t.parseJSON(t(this).attr("data-update-elements"));for(var s in u)"checkbox"===t(u[s]).attr("type")?o[s]===!0||1===o[s]?t(u[s]).attr("checked","checked"):t(u[s]).removeAttr("checked"):l&&o[s]&&-1==o[s].indexOf("Create New")||!l?t(u[s]).val(o[s]).trigger("change"):t(u[s]).val("").trigger("change")}var c=this.value;return t(this).bind("keyup.clearId",function(){t.trim(t(this).val())!=t.trim(c)&&(t(t(this).attr("data-id-element")).val("").trigger("change"),t(this).unbind("keyup.clearId"))}),t(e).trigger("railsAutocomplete.select",r),!1}}),t(e).trigger("railsAutocomplete.init")}}),t(document).ready(function(){t("input[data-autocomplete]").railsAutocomplete("input[data-autocomplete]")})}(jQuery);

View File

@@ -0,0 +1,102 @@
(function () {
// Helper: escape HTML for safety
function escapeHtml(str) {
return $("<div>").text(str).html();
}
// Helper: highlight all occurrences of term (case-insensitive)
function highlightTerm(text, term) {
if (!term) return text;
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp("(" + escapedTerm + ")", "ig");
return text.replace(regex, "<strong>$1</strong>");
}
window.initCustomerAutocomplete = function(context) {
let scope = context || document;
$(scope).find(".customer-name").each(function() {
if ($(this).data("autocomplete-initialized")) return;
$(this).data("autocomplete-initialized", true);
let $input = $(this);
let ac = $input.autocomplete({
appendTo: "body", // crucial for Redmine positioning
minLength: 2,
source: function(request, response) {
$.getJSON("/customers/autocomplete", { q: request.term })
.done(function(data) {
response(data.map(function(item) {
// combine secondary info
let secondary = [];
if (item.phone_number) secondary.push(item.phone_number);
if (item.mobile_phone_number) secondary.push(item.mobile_phone_number);
let meta = secondary.length ? " (" + secondary.join(" • ") + ")" : "";
// escape HTML to avoid XSS
let safeText = escapeHtml(item.name + meta);
return {
label: item.name + meta, // plain fallback
value: item.name, // goes into input
id: item.id,
html: highlightTerm(safeText, request.term)
};
}));
})
.fail(function() {
response([]);
});
},
select: function(event, ui) {
$input.val(ui.item.value); // visible text
$("#customer_id").val(ui.item.id); // hidden ID
// trigger Redmine form update safely
setTimeout(function() {
$("#customer_id").trigger("change");
}, 0);
return false;
},
change: function(event, ui) {
// clear hidden field if no valid selection
if (!ui.item && !$input.val()) {
$("#customer_id").val("");
}
}
});
// Render item HTML for highlight
ac.autocomplete("instance")._renderItem = function(ul, item) {
return $("<li>")
.append($("<div>").html(item.html))
.appendTo(ul);
};
});
};
// Re-init after Redmine AJAX form updates
$(document).on("ajaxComplete", function() {
if (window.initCustomerAutocomplete) {
window.initCustomerAutocomplete(document);
}
});
// Init on page load
$(document).ready(function() {
window.initCustomerAutocomplete(document);
});
// Also init on Turbo/Redmine load events
document.addEventListener("turbo:load", function() {
window.initCustomerAutocomplete(document);
});
})();

View File

@@ -0,0 +1,5 @@
/* Keep Redmine default look, just enhance metadata */
.ui-autocomplete .autocomplete-meta {
color: #888;
font-size: 0.9em;
}

View File

@@ -14,11 +14,15 @@ get 'qbo/oauth_callback', to: 'qbo#oauth_callback'
#manual sync #manual sync
get 'qbo/sync', to: 'qbo#sync' get 'qbo/sync', to: 'qbo#sync'
get 'invoices/sync', to: 'invoices#sync'
get 'estimates/sync', to: 'estimates#sync'
get 'employees/sync', to: 'employees#sync'
#webhook #webhook
post 'qbo/webhook', to: 'qbo#webhook' post 'qbo/webhook', to: 'qbo#webhook'
# Estimate & Invoice PDF # Estimate & Invoice PDF
get 'estimates/:id', to: 'estimates#show', as: :estimate
get 'estimates/doc/', to: 'estimates#doc', as: :estimate_doc get 'estimates/doc/', to: 'estimates#doc', as: :estimate_doc
get 'invoices/:id', to: 'invoices#show', as: :invoice get 'invoices/:id', to: 'invoices#show', as: :invoice
@@ -34,19 +38,8 @@ get 'filter_estimates_by_customer' => 'customers#filter_estimates_by_customer'
get 'filter_invoices_by_customer' => 'customers#filter_invoices_by_customer' get 'filter_invoices_by_customer' => 'customers#filter_invoices_by_customer'
resources :customers do resources :customers do
get :autocomplete_customer_name, on: :collection collection do
get :sync get :autocomplete
end get :sync
end
resources :estimates do end
get :sync
get :doc, as: :estimate_doc
end
resources :employees do
get :sync
end
resources :invoices do
get :sync
end

View File

@@ -14,7 +14,7 @@ Redmine::Plugin.register :redmine_qbo do
name 'Redmine QBO plugin' name 'Redmine QBO plugin'
author 'Rick Barrette' author 'Rick Barrette'
description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed' description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed'
version '2026.3.11' version '2026.3.14'
url 'https://github.com/rickbarrette/redmine_qbo' url 'https://github.com/rickbarrette/redmine_qbo'
author_url 'https://barrettefabrication.com' author_url 'https://barrettefabrication.com'
settings default: {empty: true}, partial: 'qbo/settings' settings default: {empty: true}, partial: 'qbo/settings'

View File

@@ -23,13 +23,12 @@ module RedmineQbo
project = context[:project] project = context[:project]
# Customer Name Text Box with database backed autocomplete # Customer Name Text Box with database backed autocomplete
# onchange event will update the hidden customer_id field # onchange event will update the hidden customer_id field
search_customer = f.autocomplete_field :customer, search_customer = f.text_field :customer,
autocomplete_customer_name_customers_path, class: "customer-name",
selected: issue.customer, autocomplete: "off",
update_elements: { data: {
id: '#issue_customer_id', autocomplete_url: "/customers/autocomplete"
value: '#issue_customer'
} }
# We need to handle 3 cases for the onchange event of the customer name field: # We need to handle 3 cases for the onchange event of the customer name field:
@@ -47,7 +46,7 @@ module RedmineQbo
# This hidden field is used for the customer ID for the issue # This hidden field is used for the customer ID for the issue
# the onchange event will reload the issue form via ajax to update the available estimates # the onchange event will reload the issue form via ajax to update the available estimates
customer_id = f.hidden_field :customer_id, customer_id = f.hidden_field :customer_id,
id: "issue_customer_id", id: "customer_id",
onchange: js_path.html_safe onchange: js_path.html_safe
# Generate the drop down list of quickbooks estimates owned by the selected customer # Generate the drop down list of quickbooks estimates owned by the selected customer

View File

@@ -17,8 +17,9 @@ module RedmineQbo
def view_layouts_base_html_head(context = {}) def view_layouts_base_html_head(context = {})
safe_join([ safe_join([
javascript_include_tag( 'application.js', plugin: :redmine_qbo), javascript_include_tag( 'application.js', plugin: :redmine_qbo),
javascript_include_tag( 'autocomplete-rails.js', plugin: :redmine_qbo), javascript_include_tag( 'autocomplete.js', plugin: :redmine_qbo),
javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo) javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo),
stylesheet_link_tag( 'autocomplete', plugin: :redmine_qbo)
]) ])
end end