Compare commits

17 Commits

Author SHA1 Message Date
ricky 0acf5ffca4 2026.4.2 2026-04-17 21:32:11 -04:00
ricky 5b1d98aa07 Show running total even if issue doesn't have line items but it's children do 2026-04-17 21:31:46 -04:00
ricky 3ca45a457f 2026.4.1 2026-04-17 21:14:09 -04:00
ricky c9d2a47a92 Added running total calculated using subtask totals from the entire issue tree 2026-04-17 21:13:33 -04:00
ricky b939d834e9 2026.4.0 2026-04-03 11:59:28 -04:00
ricky 9b9a5c3505 Preventing Implicit Deletions 2026-04-03 11:58:56 -04:00
ricky da49b996da removed redundant private 2026-03-22 18:31:24 -04:00
ricky 50a02cc497 2026.3.12 2026-03-22 14:26:22 -04:00
ricky b63e8f2a45 Updated hooks & patches 2026-03-21 20:02:11 -04:00
ricky 4b561ef4e3 added progressive row creation 2026-03-19 12:44:27 -04:00
ricky 1dadcf37b6 Added screenshots for items and accounts 2026-03-19 09:12:38 -04:00
ricky ffaee10fef updated readme 2026-03-19 09:00:39 -04:00
ricky 58994e3c7d Updated readme 2026-03-19 08:56:47 -04:00
ricky 6365fe6679 2026.3.11 2026-03-19 08:45:32 -04:00
ricky 4d6c16373a Don't display form if issue if closed 2026-03-19 08:38:21 -04:00
ricky 20b7564c38 Added items to admin menu 2026-03-19 07:13:43 -04:00
ricky 9820646857 allow math on unit price field 2026-03-18 22:02:53 -04:00
17 changed files with 262 additions and 95 deletions
+4 -5
View File
@@ -10,8 +10,6 @@ This plugin allows **billable line items** to be attached to a Redmine issue. Wh
* **Redmine:** 6.1+ * **Redmine:** 6.1+
* **Ruby:** 3.2+
* **Parent Plugin:** [Redmine QuickBooks Online](https://github.com/rickbarrette/redmine_qbo) (must be installed and configured) * **Parent Plugin:** [Redmine QuickBooks Online](https://github.com/rickbarrette/redmine_qbo) (must be installed and configured)
@@ -19,9 +17,9 @@ This plugin allows **billable line items** to be attached to a Redmine issue. Wh
## Compatibility ## Compatibility
| Plugin Version | Redmine Version | Ruby Version | | Plugin Version | Redmine Version | Parent Plugin Version |
| --- | --- | --- | | --- | --- | --- |
| 2026.3.6+ | 6.1.x | 3.2+ | | 2026.3.8+ | 6.1.x | 2026.3.9+ |
--- ---
@@ -88,8 +86,9 @@ Before using this plugin:
2. Ensure your **QuickBooks Online** company file is connected. 2. Ensure your **QuickBooks Online** company file is connected.
3. Verify that the products or services referenced in line items exist in QuickBooks. 3. Sync Accounts & Items via plugin settings
4. Set default income account for new items via plugin settings
--- ---
Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

-2
View File
@@ -100,8 +100,6 @@ class ItemsController < ApplicationController
params.require(:item).permit(:name, :description, :sku, :unit_price, :active, :account_id, :type, :taxable) params.require(:item).permit(:name, :description, :sku, :unit_price, :active, :account_id, :type, :taxable)
end end
private
def log(msg) def log(msg)
Rails.logger.info "[ItemsController] #{msg}" Rails.logger.info "[ItemsController] #{msg}"
end end
+4 -1
View File
@@ -15,7 +15,10 @@
</p> </p>
<p> <p>
<%= f.number_field :unit_price, step: 0.01, size: 10 %> <%= f.text_field :unit_price,
class: "price-field",
inputmode: "decimal",
autocomplete: "off"%>
</p> </p>
<p> <p>
@@ -1,8 +1,12 @@
<% if @issue.line_items.any? %> <% if @issue.line_items.any? || @issue.descendant_line_items_total > 0%>
<hr/> <hr/>
<div> <div>
<p><strong><%= t :label_line_items %></strong></p> <p><strong><%= t :label_line_items %></strong></p>
<% total = 0 %>
<% if @issue.line_items.any?%>
<table class="list line-items-table"> <table class="list line-items-table">
<thead> <thead>
<tr> <tr>
@@ -14,7 +18,6 @@
</thead> </thead>
<tbody> <tbody>
<% total = 0 %>
<% @issue.line_items.each do |item| %> <% @issue.line_items.each do |item| %>
<% line_total = item.quantity.to_f * item.unit_price.to_f %> <% line_total = item.quantity.to_f * item.unit_price.to_f %>
@@ -29,11 +32,26 @@
<% end %> <% end %>
</tbody> </tbody>
<% end %>
<tfoot> <tfoot>
<% if @issue.line_items.any?%>
<tr> <tr>
<td colspan="3" style="text-align:right;"><strong><%= t :label_total %></strong></td> <td colspan="3" style="text-align:right;"><strong><%= t :label_total %></strong></td>
<td><strong><%= number_to_currency(total) %></strong></td> <td>
<strong><%= number_to_currency(total) %></strong>
</td>
</tr> </tr>
<% end %>
<% if @issue.descendant_line_items_total > 0 %>
<tr>
<td colspan="3" style="text-align:right;"><strong><%= t :label_running_total %></strong></td>
<td>
<strong>(<%= number_to_currency(@issue.descendant_line_items_total + total) %>)</strong>
</td>
</tr>
<% end %>
</tfoot> </tfoot>
</table> </table>
</div> </div>
+96 -5
View File
@@ -1,3 +1,9 @@
let lastKeyWasTab = false;
document.addEventListener("keydown", function (e) {
lastKeyWasTab = (e.key === "Tab");
});
(function () { (function () {
function initNestedForms() { function initNestedForms() {
document.querySelectorAll("[data-nested-form]").forEach(function (wrapper) { document.querySelectorAll("[data-nested-form]").forEach(function (wrapper) {
@@ -22,11 +28,28 @@
Date.now().toString() Date.now().toString()
); );
//container.insertAdjacentHTML("beforeend", content);
container.insertAdjacentHTML("beforeend", content); container.insertAdjacentHTML("beforeend", content);
// initialize autocomplete on the new row const newRow = container.lastElementChild;
initLineItemAutocomplete(container.lastElementChild);
// Ensure clean state
newRow.dataset.autoAdded = "false";
// Reset defaults
const qty = newRow.querySelector(".qty-field");
if (qty && !qty.value) qty.value = 1;
const price = newRow.querySelector(".price-field");
if (price) price.value = "";
// initialize autocomplete
initLineItemAutocomplete(newRow);
// Only focus if NOT tabbing
if (!lastKeyWasTab) {
const desc = newRow.querySelector(".line-item-description");
if (desc) desc.focus();
}
} }
// REMOVE // REMOVE
@@ -56,9 +79,77 @@
document.addEventListener("turbo:load", initNestedForms); document.addEventListener("turbo:load", initNestedForms);
})(); })();
$(document).on("input", ".line-item-description", function(){
// Keep your existing behavior
$(document).on("input", ".line-item-description", function () {
let row = $(this).closest(".line-item"); let row = $(this).closest(".line-item");
row.find(".item-id-field").val(""); row.find(".item-id-field").val("");
}); });
// -------------------------------
// AUTO-ADD NEW ROW LOGIC
// -------------------------------
// Reset autoAdded flag if cleared
document.addEventListener("input", function (e) {
if (!e.target.classList.contains("line-item-description")) return;
const row = e.target.closest(".line-item");
if (!row) return;
if (e.target.value.trim() === "") {
row.dataset.autoAdded = "false";
}
});
// Add row when leaving last description (without breaking TAB flow)
document.addEventListener("blur", function (e) {
if (!e.target.classList.contains("line-item-description")) return;
const input = e.target;
const row = input.closest(".line-item");
if (!row) return;
const wrapper = input.closest("[data-nested-form]");
if (!wrapper) return;
const container = wrapper.querySelector("[data-nested-form-container]");
if (!container) return;
// Active (visible + not destroyed) rows only
const rows = Array.from(
container.querySelectorAll(wrapper.dataset.wrapperSelector)
).filter(r => {
const destroy = r.querySelector("input[name*='[_destroy]']");
const hidden = window.getComputedStyle(r).display === "none";
return !(destroy && destroy.value === "1") && !hidden;
});
const lastRow = rows[rows.length - 1];
// Only last row
if (row !== lastRow) return;
// Must have content
if (input.value.trim() === "") return;
// Prevent duplicate firing
if (row.dataset.autoAdded === "true") return;
// If TAB, ensure user is leaving the row entirely
if (lastKeyWasTab) {
const next = document.activeElement;
if (row.contains(next)) {
return; // still inside row → allow normal tabbing
}
}
row.dataset.autoAdded = "true";
const addButton = wrapper.querySelector("[data-nested-form-add]");
if (addButton) addButton.click();
}, true); // capture phase required for blur
+1
View File
@@ -33,6 +33,7 @@ en:
label_no: "No" label_no: "No"
label_qty: "Quantity" label_qty: "Quantity"
label_remove: "Remove" label_remove: "Remove"
label_running_total: "Running Total"
label_sync_now_accounts: "Sync Accounts" label_sync_now_accounts: "Sync Accounts"
label_sync_now_items: "Sync Items" label_sync_now_items: "Sync Items"
label_type: "Type" label_type: "Type"
+9 -7
View File
@@ -14,7 +14,7 @@ Redmine::Plugin.register :redmine_qbo_lineitems do
name 'Redmine QBO Line Items plugin' name 'Redmine QBO Line Items plugin'
author 'Rick Barrette' author 'Rick Barrette'
description 'A plugin for Redmine to extend the capabilitys of the Redmine QuickBooks Online plugin to attach billable line items to an isuue' description 'A plugin for Redmine to extend the capabilitys of the Redmine QuickBooks Online plugin to attach billable line items to an isuue'
version '2026.3.10' version '2026.4.2'
url 'https://github.com/rickbarrette/redmine_qbo_lineitems' url 'https://github.com/rickbarrette/redmine_qbo_lineitems'
author_url 'https://barrettefabrication.com' author_url 'https://barrettefabrication.com'
requires_redmine version_or_higher: '6.1.0' requires_redmine version_or_higher: '6.1.0'
@@ -31,10 +31,12 @@ Redmine::Plugin.register :redmine_qbo_lineitems do
Issue.safe_attributes :line_items_attributes Issue.safe_attributes :line_items_attributes
end end
# Dynamically load all Hooks & Patches recursively # Administration menu extension
base_dir = File.join(File.dirname(__FILE__), 'lib') Redmine::MenuManager.map :admin_menu do |menu|
menu.push :redmine_qbo_lineitems, { controller: 'items', action: 'index' },
# '**' looks inside subdirectories, '*.rb' matches Ruby files icon: 'list',
Dir.glob(File.join(base_dir, '**', '*.rb')).sort.each do |file| caption: :label_items,
require file html: { class: 'icon icon-list' }
end end
RedmineQboLineItems.setup
@@ -8,10 +8,10 @@
# #
#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.
module RedmineQboLineItems module LineItems
module Hooks module Hooks
class IssuesSaveHookListener < Redmine::Hook::ViewListener class IssuesSaveHookListener < Redmine::Hook::Listener
# Called After Issue Saved # Called After Issue Saved
def controller_issues_edit_after_save(context={}) def controller_issues_edit_after_save(context={})
@@ -8,10 +8,10 @@
# #
#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.
module RedmineQboLineItems module LineItems
module Hooks module Hooks
class QboHookListener < Redmine::Hook::ViewListener class QboHookListener < Redmine::Hook::Listener
# Called by WebhookProcessJob # Called by WebhookProcessJob
def qbo_additional_entities(context={}) def qbo_additional_entities(context={})
@@ -8,7 +8,7 @@
# #
#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.
module RedmineQboLineItems module LineItems
module Hooks module Hooks
class ViewHookListener < Redmine::Hook::ViewListener class ViewHookListener < Redmine::Hook::ViewListener
@@ -25,6 +25,7 @@ module RedmineQboLineItems
end end
def view_issues_edit_notes_bottom(context = {}) def view_issues_edit_notes_bottom(context = {})
return if context[:issue].closed?
context[:controller].send(:render_to_string, { context[:controller].send(:render_to_string, {
partial: 'line_items/issue_form', partial: 'line_items/issue_form',
locals: { locals: {
+75
View File
@@ -0,0 +1,75 @@
#The MIT License (MIT)
#
#Copyright (c) 2026 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.
module LineItems
module Patches
module IssuePatch extend ActiveSupport::Concern
prepended do
has_many :line_items, dependent: :destroy
accepts_nested_attributes_for :line_items, allow_destroy: true, reject_if: proc { |attrs| attrs['description'].blank? }
# Returns line items for immediate children
def children_line_items
LineItem.where(issue_id: self.children.pluck(:id))
end
# Calculates the total value of all child line items
def children_line_items_total
children_line_items.sum(:line_total)
end
# Returns line items for the entire tree below this issue
def descendant_line_items
LineItem.where(issue_id: self.descendants.pluck(:id))
end
# Calculates the total value of entire tree below this issue
def descendant_line_items_total
descendant_line_items.sum(:line_total)
end
def line_items_total
line_items.sum(:line_total)
end
def line_items_attributes=(attrs)
attrs = attrs.stringify_keys
# IDs submitted in the form
submitted_ids = attrs.values.map { |a| a['id'] }.compact.map(&:to_s)
# Existing IDs in DB
existing_ids = line_items.pluck(:id).map(&:to_s)
# Find missing ones (these would be implicitly deleted by Rails)
missing_ids = existing_ids - submitted_ids
# Re-add missing records so Rails doesn't delete them
missing_ids.each do |id|
attrs["preserve_#{id}"] = { 'id' => id }
end
# Only allow explicit deletes or valid updates/creates
filtered = attrs.select do |_, item_attrs|
item_attrs['_destroy'] == '1' ||
item_attrs['id'].present? ||
item_attrs['description'].present?
end
super(filtered)
rescue => e
logger.error "Error processing line items attributes: #{e.message}"
end
end
end
end
end
@@ -8,35 +8,14 @@
# #
#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.
require_dependency 'issue'
module RedmineQboLineItems module RedmineQboLineItems
module Patches
module IssuePatch
def self.included(base) def self.setup
base.extend(ClassMethods) unless Issue.ancestors.include?(LineItems::Patches::IssuePatch)
base.send(:include, InstanceMethods) Issue.prepend LineItems::Patches::IssuePatch
LineItems::Hooks::IssuesSaveHookListener
base.class_eval do LineItems::Hooks::QboHookListener
has_many :line_items, dependent: :destroy LineItems::Hooks::ViewHookListener
accepts_nested_attributes_for :line_items,
allow_destroy: true,
reject_if: proc { |attrs| attrs['description'].blank? }
end end
end
module ClassMethods
end
module InstanceMethods
end
end
Issue.send(:include, IssuePatch)
end end
end end