38 Commits
1.0.0 ... 1.1.1

Author SHA1 Message Date
332f07c93d Version 1.1.1 2022-03-06 07:51:39 -05:00
54d4be9762 Only show sidebar views when user is logged in 2022-03-06 07:32:01 -05:00
f1e3c29c97 Added Load Customer Link on Issue Form 2022-03-05 08:26:57 -05:00
66d393a465 Dynamically load hooks/patches & require redmine4+ 2022-03-02 07:15:54 -05:00
218d3392f0 Moved string to en.yml 2022-02-24 18:34:17 -05:00
0136d91cc3 Comments & formatting 2022-02-24 04:58:41 -05:00
a95f0350d8 Fixed missing transation error 2022-02-23 20:20:47 -05:00
55c04b6585 update Issue form on customer name change 2022-02-23 18:36:08 -05:00
ea21bc362a autocomplete off for forms & search 2022-02-23 18:24:57 -05:00
117d92b879 Fixed customer sorting & removed customer filter 2022-02-21 20:23:57 -05:00
440c8e4618 list issues in desc order 2022-02-21 19:33:38 -05:00
1344526f7f update txn_date too 2022-02-21 08:51:24 -05:00
19acfbc76f Added logging 2022-02-21 08:51:01 -05:00
9dfb27f0a4 Prevent webhook loops 2022-02-21 07:54:24 -05:00
51cd830710 Updated screenshots 2022-02-21 06:53:50 -05:00
956ba2ad46 Version 1.1.0 2022-02-21 06:25:51 -05:00
3ae3107760 desc sort issues, estimates, & invoices 2022-02-21 06:22:13 -05:00
925d4b8bcf Updated comments & removed unused code 2022-02-21 06:06:24 -05:00
ca6dbfd12d Removed duplicate private declaration 2022-02-21 05:26:54 -05:00
9ea03d0c6d Removed sync on view 2022-02-21 05:23:11 -05:00
6ad4929d53 Sync Estimates & Invoices on database update 2022-02-21 05:17:35 -05:00
446f419af0 Sync invoice when viewing 2022-02-20 19:37:03 -05:00
f3c5de82e0 Bug fix 2022-02-20 18:02:37 -05:00
56e24752cf Import invoice fix 2022-02-20 17:53:01 -05:00
255af13b20 Add txn_date to invoice & estimate databse 2022-02-20 17:40:41 -05:00
02b4f1eb43 Added Invoice date 2022-02-20 17:26:24 -05:00
8c735d3921 Added Estimate date 2022-02-20 17:19:56 -05:00
70e6038215 Moved customer issue counts 2022-02-20 15:22:31 -05:00
fc7501c4fe address not a 2022-02-20 14:57:21 -05:00
45b60cfea1 PhysicalAddress to_s 2022-02-20 14:52:11 -05:00
09313ad471 exclude before filter for customer/view 2022-02-20 13:42:06 -05:00
1b15aecbff Disable autocomplete suggestions for search 2022-02-20 08:11:57 -05:00
2bea7dbc8d fixed customer link - missing view issues/history 2022-02-20 07:56:25 -05:00
3468b5f236 Open links in new window 2022-02-19 22:53:39 -05:00
1c431d14dc remove gem faraday_middleware & set oauth2 1.4.7 2022-02-19 22:48:44 -05:00
7234a70265 Added allowed params for qbo controller 2022-02-19 21:47:12 -05:00
a459d84b00 Added Estimate & Invoice List to Customer view 2022-02-19 21:19:08 -05:00
49d2ed8244 Readme update 2022-02-19 20:56:54 -05:00
34 changed files with 370 additions and 172 deletions

View File

@@ -1,13 +1,12 @@
source 'https://rubygems.org' source 'https://rubygems.org'
gem 'quickbooks-ruby' gem 'quickbooks-ruby'
gem 'oauth2' gem 'oauth2', '1.4.7'
gem 'roxml' gem 'roxml'
gem 'nhtsa_vin' gem 'nhtsa_vin'
gem 'will_paginate' gem 'will_paginate'
gem 'rails-jquery-autocomplete' gem 'rails-jquery-autocomplete'
gem 'jquery-ui-rails' gem 'jquery-ui-rails'
gem 'faraday_middleware', '1.2.0'
group :assets do group :assets do
gem 'coffee-rails' gem 'coffee-rails'

View File

@@ -8,7 +8,7 @@ The goal of this project is to allow Redmine to connect with Quickbooks Online t
Note: Although the core functionality is complete, this project is still under heavy development. I am still working on refining everthing and adding other features. Tags should be stable Note: Although the core functionality is complete, this project is still under heavy development. I am still working on refining everthing and adding other features. Tags should be stable
Also worth metioning I am currently using this in a live production enviroment with no issues Use tags Version 1.0.0 & up for Redmine 4+ and Version 0.8.1 for Redine 3
#### Features #### Features
* Issues can be assigned to a `Customer` via drop down in the edit Issue form * Issues can be assigned to a `Customer` via drop down in the edit Issue form
@@ -52,11 +52,6 @@ Also worth metioning I am currently using this in a live production enviroment w
5. Assign an Employee to each of your users via the User Administration Page 5. Assign an Employee to each of your users via the User Administration Page
## Automatic Deploy
If you want the redmine server to be automaticly restarted after a git pull event add this hook to your git hook directory
https://gist.github.com/rickbarrette/3c999c7f37e321f9c60380de99e494f5
## Usage ## Usage
To enable automatic `Time Activity` entries for an Issue , you need only to assign a `Customer` to an Issue via drop downs in the issue creation/update form. To enable automatic `Time Activity` entries for an Issue , you need only to assign a `Customer` to an Issue via drop downs in the issue creation/update form.
@@ -79,7 +74,7 @@ Note: After the inital synchronization, this plugin will recieve push notificati
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2020 rick barrette Copyright (c) 2022 rick barrette
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -28,7 +28,7 @@ class CustomersController < ApplicationController
helper :timelog helper :timelog
before_action :add_customer, :only => :new before_action :add_customer, :only => :new
before_action :view_customer, :except => :new 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]
default_search_scope :names default_search_scope :names
@@ -89,7 +89,10 @@ class CustomersController < ApplicationController
begin begin
@customer = Customer.find_by_id(params[:id]) @customer = Customer.find_by_id(params[:id])
@vehicles = @customer.vehicles.paginate(:page => params[:page]) @vehicles = @customer.vehicles.paginate(:page => params[:page])
@issues = @customer.issues @issues = @customer.issues.order(id: :desc)
@billing_address = address_to_s(@customer.billing_address)
@shipping_address = address_to_s(@customer.shipping_address)
@closed_issues = (@issues - @issues.open)
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render_404 render_404
end end
@@ -189,4 +192,18 @@ class CustomersController < ApplicationController
found_non_zero found_non_zero
end end
# format a quickbooks address to a human readable string
def address_to_s (address)
return if address.nil?
string = address.line1
string << "\n" + address.line2 if address.line2
string << "\n" + address.line3 if address.line3
string << "\n" + address.line4 if address.line4
string << "\n" + address.line5 if address.line5
string << " " + address.city
string << ", " + address.country_sub_division_code
string << " " + address.postal_code
return string
end
end end

View File

@@ -18,6 +18,10 @@ class QboController < ApplicationController
before_action :require_user, :except => :qbo_webhook before_action :require_user, :except => :qbo_webhook
skip_before_action :verify_authenticity_token, :check_if_login_required, :only => [:qbo_webhook] skip_before_action :verify_authenticity_token, :check_if_login_required, :only => [:qbo_webhook]
def allowed_params
params.permit(:code, :state, :realmId, :id)
end
# #
# Called when the QBO Top Menu us shown # Called when the QBO Top Menu us shown
# #
@@ -88,7 +92,7 @@ class QboController < ApplicationController
# Quickbooks Webhook Callback # Quickbooks Webhook Callback
def qbo_webhook def qbo_webhook
logger.debug "Quickbooks is calling webhook" logger.info "Quickbooks is calling webhook"
# check the payload # check the payload
signature = request.headers['intuit-signature'] signature = request.headers['intuit-signature']
@@ -145,14 +149,14 @@ class QboController < ApplicationController
render nothing: true, status: 400 render nothing: true, status: 400
end end
logger.debug "Quickbooks webhook complete" logger.info "Quickbooks webhook complete"
end end
# #
# Synchronizes the QboCustomer table with QBO # Synchronizes the QboCustomer table with QBO
# #
def sync def sync
logger.debug "Syncing EVERYTHING" logger.info "Syncing EVERYTHING"
# Update info in background # Update info in background
Thread.new do Thread.new do
if Qbo.exists? if Qbo.exists?

View File

@@ -62,6 +62,8 @@ class VehiclesController < ApplicationController
begin begin
@vehicle = Vehicle.find_by_id(params[:id]) @vehicle = Vehicle.find_by_id(params[:id])
@vin = @vehicle.vin.scan(/.{1,9}/) if @vehicle.vin @vin = @vehicle.vin.scan(/.{1,9}/) if @vehicle.vin
@issues = @vehicle.issues.order(id: :desc)
@closed_issues = (@issues - @issues.open)
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render_404 render_404
end end

View File

@@ -23,7 +23,8 @@ class QboEstimate < ActiveRecord::Base
end end
# sync all estimates # sync all estimates
def self.sync def self.sync
logger.debug "Syncing ALL estimates"
estimates = get_base.all estimates = get_base.all
estimates.each { |estimate| estimates.each { |estimate|
process_estimate(estimate) process_estimate(estimate)
@@ -35,6 +36,7 @@ class QboEstimate < ActiveRecord::Base
# sync only one estimate # sync only one estimate
def self.sync_by_id(id) def self.sync_by_id(id)
logger.debug "Syncing estimate #{id}"
process_estimate(get_base.fetch_by_id(id)) process_estimate(get_base.fetch_by_id(id))
end end
@@ -44,15 +46,18 @@ class QboEstimate < ActiveRecord::Base
estimate = get_base.fetch_by_id(id) estimate = get_base.fetch_by_id(id)
qbo_estimate = find_or_create_by(id: id) qbo_estimate = find_or_create_by(id: id)
qbo_estimate.doc_number = estimate.doc_number qbo_estimate.doc_number = estimate.doc_number
qbo_estimate.txn_date = estimate.txn_date
qbo_estimate.save! qbo_estimate.save!
end end
# process an estimate into the database # process an estimate into the database
def self.process_estimate(estimate) def self.process_estimate(estimate)
logger.info "Processing estimate #{estimate.id}"
qbo_estimate = find_or_create_by(id: estimate.id) qbo_estimate = find_or_create_by(id: estimate.id)
qbo_estimate.doc_number = estimate.doc_number qbo_estimate.doc_number = estimate.doc_number
qbo_estimate.customer_id = estimate.customer_ref.value qbo_estimate.customer_id = estimate.customer_ref.value
qbo_estimate.id = estimate.id qbo_estimate.id = estimate.id
qbo_estimate.txn_date = estimate.txn_date
qbo_estimate.save! qbo_estimate.save!
end end
@@ -62,5 +67,35 @@ class QboEstimate < ActiveRecord::Base
estimate = base.fetch_by_id(id) estimate = base.fetch_by_id(id)
return base.pdf(estimate) return base.pdf(estimate)
end end
# Magic Method
# Maps Get/Set methods to QBO estimate object
def method_missing(sym, *arguments)
# Check to see if the method exists
if Quickbooks::Model::Estimate.method_defined?(sym)
# download details if required
pull unless @details
method_name = sym.to_s
# Setter
if method_name[-1, 1] == "="
@details.method(method_name).call(arguments[0])
# Getter
else
return @details.method(method_name).call
end
end
end
private
# pull the details
def pull
begin
raise Exception unless self.id
@details = Qbo.get_base(:estimate).fetch_by_id(self.id)
rescue Exception => e
@details = Quickbooks::Model::Estimate.new
end
end
end end

View File

@@ -10,13 +10,12 @@
class QboInvoice < ActiveRecord::Base class QboInvoice < ActiveRecord::Base
unloadable unloadable
has_and_belongs_to_many :issues has_and_belongs_to_many :issues
belongs_to :customer belongs_to :customer
#attr_accessible :doc_number, :id validates_presence_of :doc_number, :id, :customer_id, :txn_date
validates_presence_of :doc_number, :id
self.primary_key = :id self.primary_key = :id
# Get the quickbooks-ruby base for invoice
def self.get_base def self.get_base
Qbo.get_base(:invoice) Qbo.get_base(:invoice)
end end
@@ -25,17 +24,18 @@ class QboInvoice < ActiveRecord::Base
def self.sync def self.sync
logger.debug "Syncing all invoices" logger.debug "Syncing all invoices"
last = Qbo.first.last_sync last = Qbo.first.last_sync
query = "SELECT Id, DocNumber FROM Invoice" query = "SELECT Id, DocNumber FROM Invoice"
query << " WHERE Metadata.LastUpdatedTime >= '#{last.iso8601}' " if last query << " WHERE Metadata.LastUpdatedTime >= '#{last.iso8601}' " if last
# TODO actually do something with the above query
# .all() is never called since count is never initialized
if count == 0 if count == 0
invoices = get_base.all invoices = get_base.all
else else
invoices = get_base.query() invoices = get_base.query()
end end
# Update the invoice table
invoices.each { | invoice | invoices.each { | invoice |
process_invoice invoice process_invoice invoice
} }
@@ -44,14 +44,13 @@ class QboInvoice < ActiveRecord::Base
#sync by invoice ID #sync by invoice ID
def self.sync_by_id(id) def self.sync_by_id(id)
logger.debug "Syncing invoice #{id}" logger.debug "Syncing invoice #{id}"
#update the information in the database
invoice = get_base.fetch_by_id(id) invoice = get_base.fetch_by_id(id)
process_invoice invoice process_invoice invoice
end end
private private
# Attach the invoice to the issue # Attach the invoice to the issue
def self.attach_to_issue(issue, invoice) def self.attach_to_issue(issue, invoice)
return if issue.nil? return if issue.nil?
@@ -59,14 +58,9 @@ class QboInvoice < ActiveRecord::Base
return if issue.customer_id != invoice.customer_ref.value.to_i return if issue.customer_id != invoice.customer_ref.value.to_i
logger.debug "Attaching invoice #{invoice.id} to issue #{issue.id}" logger.debug "Attaching invoice #{invoice.id} to issue #{issue.id}"
# Load the invoice into the database
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
qbo_invoice.doc_number = invoice.doc_number
qbo_invoice.id = invoice.id
qbo_invoice.customer_id = invoice.customer_ref
qbo_invoice.save!
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
unless issue.qbo_invoices.include?(qbo_invoice) unless issue.qbo_invoices.include?(qbo_invoice)
issue.qbo_invoices << qbo_invoice issue.qbo_invoices << qbo_invoice
issue.save! issue.save!
@@ -75,10 +69,19 @@ class QboInvoice < ActiveRecord::Base
compare_custom_fields(issue, invoice) compare_custom_fields(issue, invoice)
end end
# processes the invoice into the system # processes the invoice into the database
def self.process_invoice(invoice) def self.process_invoice(invoice)
logger.debug "Processing invoice" logger.info "Processing invoice #{invoice.id}"
# Check the private notes
# Load the invoice into the database
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
qbo_invoice.doc_number = invoice.doc_number
qbo_invoice.id = invoice.id
qbo_invoice.customer_id = invoice.customer_ref
qbo_invoice.txn_date = invoice.txn_date
qbo_invoice.save!
# Scan the private notes for hashtags and attach to the applicable issues
if not invoice.private_note.nil? if not invoice.private_note.nil?
invoice.private_note.scan(/#(\w+)/).flatten.each { |issue| invoice.private_note.scan(/#(\w+)/).flatten.each { |issue|
attach_to_issue(Issue.find_by_id(issue.to_i), invoice) attach_to_issue(Issue.find_by_id(issue.to_i), invoice)
@@ -95,23 +98,36 @@ class QboInvoice < ActiveRecord::Base
} }
end end
# compares the custome fields on invoices & issues and updates the invoice as needed
#
# the issue here is when two or more issues share an invoice with the same custom field, but diffrent values
# this condions causes an infinite loop as the webhook is called when an invoice is updated
# TODO maybe add a cf_sync_confict flag to invoices
def self.compare_custom_fields(issue, invoice) def self.compare_custom_fields(issue, invoice)
logger.debug "Comparing custom fields"
# TODO break if QboInvoice.find(invoice.id).cf_sync_confict
is_changed = false is_changed = false
# update the invoive custom fields with infomation from the work ticket if available # update the invoive custom fields with infomation from the issue if available
invoice.custom_fields.each { |cf| invoice.custom_fields.each { |cf|
# TODO Add some hooks here
# VIN from the attached vehicle # VIN from the attached vehicle
# TODO move this into seperate plugin
# TODO create hook for seperate plugin
begin begin
if cf.name.eql? "VIN" if cf.name.eql? "VIN"
vin = Vehicle.find(issue.vehicles_id).vin # Only update if blank to prevent infite loops
break if vin.nil? # TODO check cf_sync_confict flag once implemented
if not cf.string_value.to_s.eql? vin if cf.string_value.to_s.blank?
cf.string_value = vin.to_s logger.debug " VIN was blank, updating the invoice vin in quickbooks"
logger.debug "VIN has changed" vin = Vehicle.find(issue.vehicles_id).vin
is_changed = true break if vin.nil?
if not cf.string_value.to_s.eql? vin
cf.string_value = vin.to_s
logger.debug "VIN has changed"
is_changed = true
end
end end
end end
rescue rescue
@@ -126,43 +142,55 @@ class QboInvoice < ActiveRecord::Base
if not value.value.to_s.blank? if not value.value.to_s.blank?
# Check to see if the value is diffrent # Check to see if the value is diffrent
if not cf.string_value.to_s.eql? value.value.to_s if not cf.string_value.to_s.eql? value.value.to_s
# update the custom field on the invoice
# Use the lowest Milage cf.string_value = value.value.to_s
if cf.name.eql? "Mileage In" is_changed = true
if cf.string_value.to_i > value.value.to_i or cf.string_value.blank?
cf.string_value = value.value.to_s
is_changed = true
end
# Use the max milage
elsif cf.name.eql? "Mileage Out"
if cf.string_value.to_i < value.value.to_i or cf.string_value.blank?
cf.string_value = value.value.to_s
is_changed = true
end
else
# Everything else
cf.string_value = value.value.to_s
is_changed = true
end
end end
end end
rescue rescue
# Nothing to do here, there is no match # Nothing to do here, there is no match
end end
} }
# TODO Add some hooks here
# Push updates # Push updates
#invoice.sync_token += 1 if is_changed
begin begin
logger.debug "Trying to update invoice" logger.debug "Trying to update invoice"
get_base.update(invoice) if is_changed get_base.update(invoice) if is_changed
rescue rescue
# Do nothing, probaly too many vehicles on the invoice. This is a problem with how it's billed # Do nothing, probaly custome field sync confict on the invoice.
# This is a problem with how it's billed
# TODO Add notes in memo area # TODO Add notes in memo area
# TODO flag QboInvoice.cf_sync_confict here
logger.error "Failed to update invoice" logger.error "Failed to update invoice"
end end
end end
# Magic Method
# Maps Get/Set methods to QBO invoice object
def method_missing(sym, *arguments)
# Check to see if the method exists
if Quickbooks::Model::Invoice.method_defined?(sym)
# download details if required
pull unless @details
method_name = sym.to_s
# Setter
if method_name[-1, 1] == "="
@details.method(method_name).call(arguments[0])
# Getter
else
return @details.method(method_name).call
end
end
end
# pull the details from quickbooks
def pull
begin
raise Exception unless self.id
@details = Qbo.get_base(:invoice).fetch_by_id(self.id)
rescue Exception => e
@details = Quickbooks::Model::Invoice.new
end
end
end end

View File

@@ -17,17 +17,12 @@
<tr> <tr>
<th><%=t(:label_billing_address)%></th> <th><%=t(:label_billing_address)%></th>
<td><%= customer.billing_address %></td> <td><%= @billing_address %></td>
</tr> </tr>
<tr> <tr>
<th><%=t(:label_shipping_address)%></th> <th><%=t(:label_shipping_address)%></th>
<td><%= customer.shipping_address %></td> <td><%= @shipping_address %></td>
</tr>
<tr>
<th><%=t(:issues)%></th>
<td><%= customer.issues.count %></td>
</tr> </tr>
<tr> <tr>
@@ -35,11 +30,6 @@
<td>$<%= customer.balance %></td> <td>$<%= customer.balance %></td>
</tr> </tr>
<tr>
<th><%=t(:label_balance_with_jobs)%></th>
<td>$<%= customer.balance_with_jobs %></td>
</tr>
<tr> <tr>
<th><%=t(:field_notes)%></th> <th><%=t(:field_notes)%></th>
<td><%= customer.notes %></td> <td><%= customer.notes %></td>

View File

@@ -7,28 +7,28 @@
<div class="clearfix"> <div class="clearfix">
<%=t(:label_display_name)%> <%=t(:label_display_name)%>
<div class="input"> <div class="input">
<%= f.text_field :name, :required => true %> <%= f.text_field :name, :required => true, :autocomplete => "off" %>
</div> </div>
</div> </div>
<div class="clearfix"> <div class="clearfix">
<%=t(:label_primary_phone)%> <%=t(:label_primary_phone)%>
<div class="input"> <div class="input">
<%= f.telephone_field :primary_phone %> <%= f.telephone_field :primary_phone, :autocomplete => "off" %>
</div> </div>
</div> </div>
<div class="clearfix"> <div class="clearfix">
<%=t(:label_mobile_phone)%>: <%=t(:label_mobile_phone)%>:
<div class="input"> <div class="input">
<%= f.telephone_field :mobile_phone %> <%= f.telephone_field :mobile_phone, :autocomplete => "off" %>
</div> </div>
</div> </div>
<div class="clearfix"> <div class="clearfix">
<%=t(:label_email)%>: <%=t(:label_email)%>:
<div class="input"> <div class="input">
<%= f.email_field :email %> <%= f.email_field :email, :autocomplete => "off" %>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<%= form_tag(customers_path, :method => "get", id: "search-form") do %> <%= form_tag(customers_path, :method => "get", id: "search-form") do %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_customers) %> <%= text_field_tag :search, params[:search], placeholder: t(:label_search_customers), :autocomplete => "off" %>
<%= 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,27 +2,42 @@
<br/> <br/>
<div class="subject"> <div class="subject">
<div><h3><%=t(:label_details)%>:</h3></div> <div><h4><%=t(:label_details)%>:</h4></div>
</div> </div>
<div class="attributes"> <div class="attributes">
<div class="splitcontent"> <div class="splitcontent">
<div class="splitcontentleft"> <div class="splitcontentleft">
<%= render :partial => 'customers/details', locals: {customer: @customer} %> <%= render :partial => 'customers/details', locals: {customer: @customer} %>
<div class="splitcontent">
<div class="splitcontentleft">
<h4><%=t(:estimates)%>:</h4>
<%= render :partial => 'estimates/list', locals: {customer: @customer} %>
</div>
<div class="splitcontentleft">
<h4><%=t(:label_invoices)%>:</h4>
<%= render :partial => 'invoices/list', locals: {customer: @customer} %>
</div>
</div>
</div> </div>
<div class="splitcontentleft"> <div class="splitcontentleft">
<h4><%=t(:field_vehicles)%>:</h4> <h4><%=t(:field_vehicles)%>:</h4>
<%= render :partial => 'vehicles/list' %> <%= render :partial => 'vehicles/list' %>
<%= button_to "New Vehicle", new_customer_vehicle_path(@customer), method: :get %> <%= button_to "New Vehicle", new_customer_vehicle_path(@customer), method: :get %>
</div> </div>
</div> </div>
<br/> <br/>
<h2><%=t(:label_open_issues)%>:</h2> <h2><%=@issues.open.count%> <%=t(:label_open_issues)%>:</h2>
<%= render :partial => 'issues/list_simple', locals: {issues: @issues.open} %> <%= render :partial => 'issues/list_simple', locals: {issues: @issues.open} %>
<h2><%=t(:label_closed_issues)%>:</h2> <h2><%=@closed_issues.count%> <%=t(:label_closed_issues)%>:</h2>
<%= render :partial => 'issues/list_simple', locals: {issues: (@issues - @issues.open)} %> <%= render :partial => 'issues/list_simple', locals: {issues: @closed_issues} %>
</div> </div>

View File

@@ -0,0 +1,11 @@
<% if @customer.present? %>
<% @customer.qbo_estimates.order(doc_number: :desc).each do |estimate| %>
<div class="row">
<b><%= link_to "##{estimate.doc_number}", estimate_path(estimate), target: :_blank %></b> <%= estimate.txn_date %>
</div>
<% end %>
<% else %>
<p><%=t(:label_no_estimates)%>.</p>
<% end %>

View File

@@ -1,4 +1,4 @@
<%= form_tag("/qbo/estimate/doc", :method => "get", id: "est-search-form") do %> <%= form_tag("/qbo/estimate/doc", :method => "get", id: "est-search-form") do %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_estimates) %> <%= text_field_tag :search, params[:search], placeholder: t(:label_search_estimates), :autocomplete => "off" %>
<%= submit_tag t(:label_search), :formtarget => "_blank" %> <%= submit_tag t(:label_search), :formtarget => "_blank" %>
<% end %> <% end %>

View File

@@ -0,0 +1,11 @@
<% if @customer.present? %>
<% @customer.qbo_invoices.order(doc_number: :desc).each do |invoice| %>
<div class="row">
<b><%= link_to "##{invoice.doc_number}", invoice_path(invoice), target: :_blank %></b> <%= invoice.txn_date %>
</div>
<% end %>
<% else %>
<p><%=t(:label_no_invoices)%>.</p>
<% end %>

View File

@@ -0,0 +1,35 @@
<% reply_links = issue.notes_addable? -%>
<% for journal in journals %>
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
<div id="note-<%= journal.indice %>">
<div class="contextual">
<span class="journal-actions"><%= render_journal_actions(issue, journal, :reply_links => reply_links) %></span>
<a href="#note-<%= journal.indice %>" class="journal-link">#<%= journal.indice %></a>
</div>
<h4>
<%= avatar(journal.user, :size => "24") %>
<%= authoring journal.created_on, journal.user, :label => :label_updated_time_by %>
<%= render_private_notes_indicator(journal) %>
</h4>
<% if journal.details.any? %>
<ul class="details">
<% details_to_strings(journal.visible_details).each do |string| %>
<li><%= string %></li>
<% end %>
</ul>
<% if Setting.thumbnails_enabled? && (thumbnail_attachments = journal_thumbnail_attachments(journal)).any? %>
<div class="thumbnails">
<% thumbnail_attachments.each do |attachment| %>
<div><%= thumbnail_tag(attachment) %></div>
<% end %>
</div>
<% end %>
<% end %>
<%= render_notes(issue, journal, :reply_links => reply_links) unless journal.notes.blank? %>
</div>
</div>
<%= call_hook(:view_issues_history_journal_bottom, { :journal => journal }) %>
<% end %>
<% heads_for_wiki_formatter if User.current.allowed_to?(:edit_issue_notes, issue.project) || User.current.allowed_to?(:edit_own_issue_notes, issue.project) %>

View File

@@ -1,2 +1,6 @@
<%= render :partial => 'customers/sidebar' %> <% if User.current.logged? %>
<%= render :partial => 'estimates/sidebar' %>
<%= render :partial => 'customers/sidebar' %>
<%= render :partial => 'estimates/sidebar' %>
<% end %>

View File

@@ -21,11 +21,6 @@
<td><%= vehicle.notes %></td> <td><%= vehicle.notes %></td>
</tr> </tr>
<tr>
<th> <%= t(:issues) %> </th>
<td><%= vehicle.issues.count %></td>
</tr>
<tr> <tr>
<td> <td>
<%= button_to t(:label_edit), edit_vehicle_path(vehicle), method: :get%> <%= button_to t(:label_edit), edit_vehicle_path(vehicle), method: :get%>

View File

@@ -14,21 +14,21 @@
<div class="clearfix"> <div class="clearfix">
<%=t(:label_year)%>: <%=t(:label_year)%>:
<div class="input"> <div class="input">
<%= f.number_field :year %> <%= f.number_field :year, :autocomplete => "off" %>
</div> </div>
</div> </div>
<div class="clearfix"> <div class="clearfix">
<%=t(:label_make)%>: <%=t(:label_make)%>:
<div class="input"> <div class="input">
<%= f.text_field :make %> <%= f.text_field :make, :autocomplete => "off" %>
</div> </div>
</div> </div>
<div class="clearfix"> <div class="clearfix">
<%=t(:label_model)%>: <%=t(:label_model)%>:
<div class="input"> <div class="input">
<%= f.text_field :model %> <%= f.text_field :model, :autocomplete => "off" %>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
<%= form_tag(vehicles_path, :method => "get", id: "search-form") do %> <%= form_tag(vehicles_path, :method => "get", id: "search-form") do %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_seach_vin) %> <%= text_field_tag :search, params[:search], placeholder: t(:label_search_vin), :autocomplete => "off" %>
<%= submit_tag t(:label_search) %> <%= submit_tag t(:label_search) %>
<% end %> <% end %>

View File

@@ -4,11 +4,11 @@
<div style="text-align: left; width:90%;"> <div style="text-align: left; width:90%;">
<%= render :partial => 'vehicles/details', locals: {vehicle: @vehicle} %> <%= render :partial => 'vehicles/details', locals: {vehicle: @vehicle} %>
<p> <b> <%=t(:label_open_issues)%> </b> </p> <p><b><%=@issues.open.count%> <%=t(:label_open_issues)%> </b> </p>
<%= render :partial => 'issues/list_simple', locals: {issues: @vehicle.issues.open} %> <%= render :partial => 'issues/list_simple', locals: {issues: @issues.open} %>
<p> <b> <%=t(:label_closed_issues)%> </b> </p> <p><b><%=@closed_issues.count%> <%=t(:label_closed_issues)%> </b> </p>
<%= render :partial => 'issues/list_simple', locals: {issues: (@vehicle.issues - @vehicle.issues.open)} %> <%= render :partial => 'issues/list_simple', locals: {issues: (@closed_issues)} %>
</div> </div>

View File

@@ -73,4 +73,8 @@ en:
label_webhook_token: "Intuit QBO Webhook Token" label_webhook_token: "Intuit QBO Webhook Token"
label_oauth_expires: "OAuth2 Access Token Expires At" label_oauth_expires: "OAuth2 Access Token Expires At"
label_oauth_note: "Note: You need to authenticate with Quickbooks after saving your key and secret above" label_oauth_note: "Note: You need to authenticate with Quickbooks after saving your key and secret above"
field_customers: "Customers" field_customers: "Customers"
label_no_estimates: "No Estimates"
label_no_invoices: "No Invoices"
label_invoices: "Invoices"
label_load_customer: "Load Customer"

View File

@@ -0,0 +1,44 @@
#The MIT License (MIT)
#
#Copyright (c) 2022 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class AddTxnDates < ActiveRecord::Migration[5.1]
def change
add_column :qbo_invoices, :txn_date, :date
add_column :qbo_estimates, :txn_date, :date
reversible do |direction|
direction.up {
break unless Qbo.first
QboEstimate.reset_column_information
QboInvoice.reset_column_information
say "Sync Estimates"
QboEstimate.sync
say "Sync Invoices"
invoices = QboInvoice.get_base.all
invoices.each { |invoice|
# Load the invoice into the database
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
qbo_invoice.doc_number = invoice.doc_number
qbo_invoice.id = invoice.id
qbo_invoice.customer_id = invoice.customer_ref
qbo_invoice.txn_date = invoice.txn_date
qbo_invoice.save!
}
}
end
end
end

97
init.rb
View File

@@ -8,61 +8,52 @@
# #
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# Dynamically load all Hooks & Patches
ActiveSupport::Reloader.to_prepare do
Dir::foreach(File.join(File.dirname(__FILE__), 'lib')) do |file|
next unless /\.rb$/ =~ file
require_dependency file
end
end
Redmine::Plugin.register :redmine_qbo do Redmine::Plugin.register :redmine_qbo do
# View Hook Listeners
require_dependency 'issues_form_hook_listener'
require_dependency 'issues_save_hook_listener'
require_dependency 'issues_show_hook_listener'
require_dependency 'users_show_hook_listener'
require_dependency 'header_footer_hook_listener'
require_dependency 'projects_form_hook_listener'
require_dependency 'view_hook_listener'
# Patches to the Redmine core. Will not work in development mode
require_dependency 'issue_patch'
require_dependency 'project_patch'
require_dependency 'user_patch'
require_dependency 'query_patch'
require_dependency 'time_entry_query_patch'
require_dependency 'pdf_patch'
require_dependency 'attachments_controller_patch'
# About
name 'Redmine Quickbooks Online plugin'
author 'Rick Barrette'
description 'This is a plugin for Redmine to intergrate with Quickbooks Online to allow for seamless intergration CRM and invoicing of completed issues'
version '1.0.0'
url 'https://github.com/rickbarrette/redmine_qbo'
author_url 'http://rickbarrette.org'
settings :default => {'empty' => true}, :partial => 'qbo/settings'
# Add safe attributes # About
Issue.safe_attributes 'customer_id' name 'Redmine Quickbooks Online plugin'
Issue.safe_attributes 'qbo_item_id' author 'Rick Barrette'
Issue.safe_attributes 'qbo_estimate_id' description 'This is a plugin for Redmine to intergrate with Quickbooks Online to allow for seamless intergration CRM and invoicing of completed issues'
Issue.safe_attributes 'qbo_invoice_id' version '1.1.1'
Issue.safe_attributes 'vehicles_id' url 'https://github.com/rickbarrette/redmine_qbo'
User.safe_attributes 'qbo_employee_id' author_url 'http://rickbarrette.org'
TimeEntry.safe_attributes 'qbo_billed' settings :default => {'empty' => true}, :partial => 'qbo/settings'
Project.safe_attributes 'customer_id' requires_redmine :version_or_higher => '4.0.0'
Project.safe_attributes 'vehicle_id'
# We are playing in the sandbox
#Quickbooks.sandbox_mode = true
# set per_page globally
WillPaginate.per_page = 20
# Permissions for security # Add safe attributes for core models
permission :view_customers, :customers => :index, :public => false Issue.safe_attributes 'customer_id'
permission :add_customers, :customers => :new, :public => false Issue.safe_attributes 'qbo_item_id'
permission :view_payments, :payments => :index, :public => false Issue.safe_attributes 'qbo_estimate_id'
permission :add_payments, :payments => :new, :public => false Issue.safe_attributes 'qbo_invoice_id'
permission :view_vehicles, :payments => :new, :public => false Issue.safe_attributes 'vehicles_id'
User.safe_attributes 'qbo_employee_id'
# Register QBO top menu item TimeEntry.safe_attributes 'qbo_billed'
menu :top_menu, :customers, { :controller => :customers, :action => :index }, :caption => 'Customers', :if => Proc.new {User.current.logged?} Project.safe_attributes 'customer_id'
menu :top_menu, :vehicles, { :controller => :vehicles, :action => :index }, :caption => 'Vehicles', :if => Proc.new { User.current.logged? } Project.safe_attributes 'vehicle_id'
# We are playing in the sandbox
#Quickbooks.sandbox_mode = true
# set per_page globally
WillPaginate.per_page = 20
# Permissions for security
permission :view_customers, :customers => :index, :public => false
permission :add_customers, :customers => :new, :public => false
permission :view_payments, :payments => :index, :public => false
permission :add_payments, :payments => :new, :public => false
permission :view_vehicles, :payments => :new, :public => false
# Register top menu items
menu :top_menu, :customers, { :controller => :customers, :action => :index }, :caption => 'Customers', :if => Proc.new {User.current.logged?}
menu :top_menu, :vehicles, { :controller => :vehicles, :action => :index }, :caption => 'Vehicles', :if => Proc.new { User.current.logged? }
end end

View File

@@ -14,6 +14,6 @@ class HeaderFooterHookListener < Redmine::Hook::ViewListener
end end
def view_layouts_base_body_bottom(context = {}) def view_layouts_base_body_bottom(context = {})
return "<div id='qbo_footer' align='center'><b>Last Sync: </b> #{Qbo.last_sync if Qbo.exists?}</div>" return "<div id='qbo_footer' align='center'><b>#{I18n.translate(:label_last_sync)}: </b> #{Qbo.last_sync if Qbo.exists?}</div>"
end end
end end

View File

@@ -1,6 +1,6 @@
#The MIT License (MIT) #The MIT License (MIT)
# #
#Copyright (c) 2017 rick barrette #Copyright (c) 2022 rick barrette
# #
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: #Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
# #
@@ -18,7 +18,7 @@ class IssuesFormHookListener < Redmine::Hook::ViewListener
end end
# Edit Issue Form # Edit Issue Form
# Show a dropdown for quickbooks contacts # TODO figure out how to do this with a view
def view_issues_form_details_bottom(context={}) def view_issues_form_details_bottom(context={})
f = context[:form] f = context[:form]
@@ -28,7 +28,7 @@ class IssuesFormHookListener < Redmine::Hook::ViewListener
selected_vehicle = context[:project].vehicle ? context[:project].vehicle.id : nil selected_vehicle = context[:project].vehicle ? context[:project].vehicle.id : nil
end end
# Check to see if there is a quickbooks user attached to the issue # Check to see if there are things attached to the issue
selected_customer = context[:issue].customer ? context[:issue].customer.id : nil selected_customer = context[:issue].customer ? context[:issue].customer.id : nil
selected_estimate = context[:issue].qbo_estimate ? context[:issue].qbo_estimate.id : nil selected_estimate = context[:issue].qbo_estimate ? context[:issue].qbo_estimate.id : nil
selected_vehicle = context[:issue].vehicles_id ? context[:issue].vehicles_id : nil selected_vehicle = context[:issue].vehicles_id ? context[:issue].vehicles_id : nil
@@ -36,15 +36,22 @@ class IssuesFormHookListener < Redmine::Hook::ViewListener
# Load customer information # Load customer information
customer = Customer.find_by_id(selected_customer) if selected_customer customer = Customer.find_by_id(selected_customer) if selected_customer
# Customer Name Text Box with database backed autocomplete
search_customer = f.autocomplete_field :customer, search_customer = f.autocomplete_field :customer,
autocomplete_customer_name_customers_path, autocomplete_customer_name_customers_path,
:selected => selected_customer, :selected => selected_customer,
:update_elements => { :id => '#issue_customer_id', :value => '#issue_customer' } :onchange => "updateIssueFrom('/issues/#{context[:issue].id}/edit.js', this)",
:update_elements => {
:id => '#issue_customer_id',
:value => '#issue_customer'
}
# Customer ID - Hidden Field
customer_id = f.hidden_field :customer_id, customer_id = f.hidden_field :customer_id,
:id => "issue_customer_id", :id => "issue_customer_id",
:onchange => "updateIssueFrom('/issues/#{context[:issue].id}/edit.js', this)" :onchange => "updateIssueFrom('/issues/#{context[:issue].id}/edit.js', this)"
# Load estimates & vehicles
if context[:issue].customer if context[:issue].customer
if customer.vehicles if customer.vehicles
vehicles = customer.vehicles.pluck(:name, :id) vehicles = customer.vehicles.pluck(:name, :id)
@@ -57,11 +64,10 @@ class IssuesFormHookListener < Redmine::Hook::ViewListener
estimates = [nil].compact estimates = [nil].compact
end end
# Generate the drop down list of quickbooks extimates # Generate the drop down list of quickbooks extimates & vehicles
select_estimate = f.select :qbo_estimate_id, estimates, :selected => selected_estimate, include_blank: true select_estimate = f.select :qbo_estimate_id, estimates, :selected => selected_estimate, include_blank: true
vehicle = f.select :vehicles_id, vehicles, :selected => selected_vehicle, include_blank: true vehicle = f.select :vehicles_id, vehicles, :selected => selected_vehicle, include_blank: true
return "<p><label for=\"issue_customer\">Customer</label>#{search_customer} #{customer_id}</p> <p>#{select_estimate}</p> <p>#{vehicle}</p>" return "<p><label for=\"issue_customer\">Customer</label>#{search_customer} #{customer_id} #{link_to_function I18n.t(:label_load_customer), "updateIssueFrom('/issues/#{context[:issue].id}/edit.js', this)"}</p> <p>#{select_estimate}</p> <p>#{vehicle}</p>"
end end
end end

View File

@@ -32,7 +32,7 @@ class IssuesShowHookListener < Redmine::Hook::ViewListener
issue.qbo_invoice_ids.each do |i| issue.qbo_invoice_ids.each do |i|
invoice = QboInvoice.find i invoice = QboInvoice.find i
invoice_link = invoice_link + link_to( invoice.doc_number, "#{Redmine::Utils::relative_url_root}/qbo/invoice/#{i}", :target => "_blank").to_s + " " invoice_link = invoice_link + link_to( invoice.doc_number, "#{Redmine::Utils::relative_url_root}/qbo/invoice/#{i}", :target => "_blank").to_s + " "
invoice_link = invoice_link.html_safe invoice_link = invoice_link.html_safe
end end
end end
@@ -51,11 +51,11 @@ class IssuesShowHookListener < Redmine::Hook::ViewListener
:partial => 'qbo/issues_show_details', :partial => 'qbo/issues_show_details',
locals: { locals: {
customer: customer, customer: customer,
estimate_link: estimate_link, estimate_link: estimate_link,
invoice_link: invoice_link, invoice_link: invoice_link,
vehicle: vehicle, vehicle: vehicle,
split_vin: split_vin, split_vin: split_vin,
notes: notes notes: notes
} }
}) })
end end

View File

@@ -16,7 +16,7 @@ module QueryPatch
def available_columns def available_columns
unless @available_columns unless @available_columns
@available_columns = self.class.available_columns.dup @available_columns = self.class.available_columns.dup
@available_columns << QueryColumn.new(:customer, :sortable => "#{Customer.table_name}.name", :groupable => true, :caption => :field_customer) @available_columns << QueryColumn.new(:customer, :sortable => "#{Issue.table_name}.customer_id", :groupable => true, :caption => :field_customer)
@available_columns << QueryColumn.new(:qbo_billed, :sortable => "#{TimeEntry.table_name}.qbo_billed", :groupable => true, :caption => :field_qbo_billed) @available_columns << QueryColumn.new(:qbo_billed, :sortable => "#{TimeEntry.table_name}.qbo_billed", :groupable => true, :caption => :field_qbo_billed)
end end
super super
@@ -24,7 +24,7 @@ module QueryPatch
# Add customers to filters # Add customers to filters
def initialize_available_filters def initialize_available_filters
add_available_filter "customer", :type => :text #add_available_filter "customer", :type => :text
super super
end end

View File

@@ -1,3 +1,15 @@
#The MIT License (MIT)
#
#Copyright (c) 2022 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class ViewHookListener < Redmine::Hook::ViewListener class ViewHookListener < Redmine::Hook::ViewListener
render_on :view_layouts_base_sidebar, :partial => "qbo/sidebar" render_on :view_layouts_base_sidebar, :partial => "qbo/sidebar"
end end