Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ This codebase (Rails 8.1)
| Directory | Purpose |
|---|---|
| `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) |
| `app/frontend/javascript/controllers/` | Stimulus controllers (75) |
| `app/frontend/javascript/controllers/` | Stimulus controllers (76) |
| `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) |
| `app/frontend/stylesheets/` | Tailwind CSS and component styles |

Expand Down Expand Up @@ -310,6 +310,7 @@ end
- `grant_details` β€” Swaps a grant's eligibility criteria + tasks when the grant picker changes
- `grant_select` β€” Tom Select grant picker showing each grant's remaining-of-total funds
- `inactive_toggle` β€” Gray out expired affiliations
- `invoice_editor` β€” Live-recompute each line's amount (quantity Γ— amount-per-person) and the total as the blank event invoice template is filled in before printing
- `optimistic_bookmark` β€” Instant bookmark UI feedback
- `org_toggle` β€” Organization toggle UI
- `paginated_fields` β€” Client-side pagination of nested fields
Expand Down
32 changes: 32 additions & 0 deletions app/controllers/events/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ module Events
# event's content (line item + cost); when a `submission_id` is supplied it
# autofills the bill-to/attention from that bulk-payment submission.
class InvoicesController < ApplicationController
include AhoyTracking

# Bulk-payment payers have no account; authorization (below) gates access.
skip_before_action :authenticate_user!, only: [ :show ]
before_action :set_event
Expand All @@ -23,10 +25,40 @@ def show
@event = @event.decorate
end

# Records that an admin filled in and generated the blank template, keeping a
# usage breadcrumb (who, which event, the entered details) in Ahoy without a
# dedicated invoice model. Re-renders the filled-in form so it can be printed.
def create
authorize! @event, to: :invoice?
@invoice = EventInvoice.from_event(@event)
@entered = entered_invoice_fields

# String keys so Ahoy's resource-dimension extraction (props["resource_type"])
# ties the event to the record; `entered` is already string-keyed.
track_event("generate.invoice", {
"resource_type" => "Event",
"resource_id" => @event.id,
"resource_title" => @event.title
}.merge(@entered.to_h))

@event = @event.decorate
flash.now[:notice] = "Invoice recorded."
render :show
end

private

def set_event
@event = Event.find(params[:event_id])
end

def entered_invoice_fields
return {} unless params[:invoice].respond_to?(:permit)

params.require(:invoice).permit(
:bill_to, :attention, :number, :date, :reference, :client_id, :names,
line_item: [ :date, :description, :quantity, :unit_price ]
)
end
end
end
3 changes: 3 additions & 0 deletions app/frontend/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ application.register("grant-select", GrantSelectController)
import InactiveToggleController from "./inactive_toggle_controller"
application.register("inactive-toggle", InactiveToggleController)

import InvoiceEditorController from "./invoice_editor_controller"
application.register("invoice-editor", InvoiceEditorController)

import OptimisticBookmarkController from "./optimistic_bookmark_controller"
application.register("optimistic-bookmark", OptimisticBookmarkController)

Expand Down
38 changes: 38 additions & 0 deletions app/frontend/javascript/controllers/invoice_editor_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="invoice-editor"
// Live-recomputes each line's amount (quantity Γ— amount-per-person) and the
// invoice total as the blank template is filled in, so the printed PDF stays
// consistent. Quantity/unitPrice/lineAmount targets are paired by DOM order.
export default class extends Controller {
static targets = ["quantity", "unitPrice", "lineAmount", "total"]

connect() {
this.recompute()
}

recompute() {
let totalCents = 0
this.quantityTargets.forEach((quantityInput, i) => {
const quantity = parseFloat(quantityInput.value) || 0
const unitCents = this.toCents(this.unitPriceTargets[i]?.value)
const amountCents = Math.round(quantity * unitCents)
totalCents += amountCents
if (this.lineAmountTargets[i]) {
this.lineAmountTargets[i].textContent = this.formatDollars(amountCents)
}
})
if (this.hasTotalTarget) this.totalTarget.textContent = this.formatDollars(totalCents)
}

toCents(value) {
if (!value) return 0
const dollars = parseFloat(String(value).replace(/[^0-9.]/g, "")) || 0
return Math.round(dollars * 100)
}

formatDollars(cents) {
const whole = cents % 100 === 0
return `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: 2 })}`
}
}
21 changes: 19 additions & 2 deletions app/presenters/event_invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ def amount_cents
def details
self[:details] || []
end

# The unit price as a bare, comma-free dollar string suitable for prefilling
# an editable input (e.g. "1500" or "1500.50") β€” whole dollars drop the cents.
def unit_price_field_value
cents = unit_price_cents.to_i
return "" if cents.zero?

dollars, remainder = cents.divmod(100)
remainder.zero? ? dollars.to_s : "#{dollars}.#{remainder.to_s.rjust(2, "0")}"
end
end

# One applied payment or credit (scholarship/discount) that reduces the balance
Expand Down Expand Up @@ -72,6 +82,7 @@ def self.entry_for(allocation)

# A blank invoice template carrying only the event's content (one attendee at
# the event cost). The bill-to and attention are left empty to be filled in.
# Marked editable so the view renders it as a fillable, printable form.
def self.from_event(event)
new(
event: event,
Expand All @@ -89,7 +100,8 @@ def self.from_event(event)
quantity: 1,
unit_price_cents: event.cost_cents.to_i
)
]
],
editable: true
)
end

Expand Down Expand Up @@ -140,7 +152,8 @@ def self.attendee_detail_lines(submission)

def initialize(event:, number:, date:, client_id:, bill_to_name:,
bill_to_address_lines:, bill_to_email:, attention:, line_items:,
reference: nil, entries: [], amount_applied_cents: 0)
reference: nil, entries: [], amount_applied_cents: 0,
editable: false)
@event = event
@number = number
@date = date
Expand All @@ -153,8 +166,12 @@ def initialize(event:, number:, date:, client_id:, bill_to_name:,
@reference = reference
@entries = entries
@amount_applied_cents = amount_applied_cents
@editable = editable
end

# True only for the blank template, which the view renders as a fillable form.
def editable? = @editable

def total_cents
line_items.sum(&:amount_cents)
end
Expand Down
25 changes: 18 additions & 7 deletions app/views/events/invoices/_actions.html.erb
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
<%# Eyebrow + "Download PDF" controls above an invoice. Hidden when printing so
only the invoice document lands in the PDF. `back_path` / `back_label` set
the return link to wherever the invoice was opened from. %>
<%# Eyebrow + action controls above an invoice. Hidden when printing so only the
invoice document lands in the PDF. `back_path` / `back_label` set the return
link to wherever the invoice was opened from. When `editable` (the blank
template) a "Save record" submit button posts the filled-in form so the
generation is logged in Ahoy before the admin downloads the PDF. %>
<% editable = local_assigns.fetch(:editable, false) %>
<div class="max-w-3xl mx-auto flex flex-wrap items-center gap-2 px-4 sm:px-8 pt-4 print:hidden">
<%= link_to back_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %>
<i class="fa-solid fa-arrow-left text-xs"></i> <%= back_label %>
<% end %>
<button onclick="window.print();"
class="ml-auto inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors">
<i class="fa-solid fa-file-arrow-down"></i> Download PDF
</button>
<div class="ml-auto flex items-center gap-2">
<% if editable %>
<button type="submit" form="invoice-form"
class="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors">
<i class="fa-solid fa-floppy-disk"></i> Save record
</button>
<% end %>
<button onclick="window.print();"
class="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors">
<i class="fa-solid fa-file-arrow-down"></i> Download PDF
</button>
</div>
</div>
140 changes: 103 additions & 37 deletions app/views/events/invoices/_invoice.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
<%# Renders one EventInvoice as a printable document. Shared by the per-
registration and bulk-payment invoice pages. `invoice` is an EventInvoice. %>
<article class="mx-auto max-w-3xl bg-white text-gray-800 px-8 py-10 print:px-0 print:py-0 print:max-w-none">
registration and bulk-payment invoice pages. `invoice` is an EventInvoice.
When `invoice.editable?` (the blank template) the bill-to, attention, meta,
line item, and a notes area become fillable inputs wrapped in a form that
posts to Events::InvoicesController#create (logging the generation in Ahoy),
and the amounts/total recompute live before printing. `entered` prefills the
fields from a prior submission so values survive the save round-trip. %>
<% editable = invoice.editable? %>
<% entered = local_assigns.fetch(:entered, {}) %>
<% line_entered = entered[:line_item] || {} %>
<% field_class = "w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:border-gray-400 focus:outline-none focus:ring-0 print:border-gray-300" %>
<% if editable %>
<form action="<%= event_invoice_path(invoice.event) %>" method="post" id="invoice-form" data-turbo="false">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
<% end %>
<article class="mx-auto max-w-3xl bg-white text-gray-800 px-8 py-10 print:px-0 print:py-0 print:max-w-none"<%= %( data-controller="invoice-editor").html_safe if editable %>>
<%# Header: issuer details (left) + brand logo (right) %>
<header class="flex items-start justify-between gap-6">
<address class="not-italic text-sm leading-snug">
Expand All @@ -18,36 +31,49 @@
<%# Bill-to %>
<div class="space-y-1">
<p class="text-sm font-bold">Invoice To:</p>
<div class="rounded-lg border border-gray-300 px-4 py-3 text-sm leading-relaxed">
<p class="font-medium"><%= invoice.bill_to_name %></p>
<% invoice.bill_to_address_lines.each do |line| %>
<p><%= line %></p>
<% end %>
<% if invoice.bill_to_email.present? %>
<p class="text-gray-500"><%= invoice.bill_to_email %></p>
<% end %>
</div>
<% if editable %>
<textarea name="invoice[bill_to]" rows="3" class="<%= field_class %> leading-relaxed" placeholder="Name&#10;Street address&#10;City, State ZIP&#10;email@example.com"><%= entered[:bill_to] %></textarea>
<% else %>
<div class="rounded-lg border border-gray-300 px-4 py-3 text-sm leading-relaxed">
<p class="font-medium"><%= invoice.bill_to_name %></p>
<% invoice.bill_to_address_lines.each do |line| %>
<p><%= line %></p>
<% end %>
<% if invoice.bill_to_email.present? %>
<p class="text-gray-500"><%= invoice.bill_to_email %></p>
<% end %>
</div>
<% end %>
</div>

<%# Attention %>
<div class="space-y-1 mt-5">
<p class="text-sm font-bold">Attention:</p>
<div class="rounded-lg border border-gray-300 px-4 py-3 text-sm">
<%= invoice.attention.presence || "β€”" %>
</div>
<% if editable %>
<input type="text" name="invoice[attention]" value="<%= entered[:attention] %>" class="<%= field_class %>" placeholder="Name">
<% else %>
<div class="rounded-lg border border-gray-300 px-4 py-3 text-sm">
<%= invoice.attention.presence || "β€”" %>
</div>
<% end %>
</div>

<%# Meta row: number / date / reference / client id %>
<div class="mt-6 rounded-lg border border-gray-300 grid grid-cols-2 sm:grid-cols-4 divide-y sm:divide-y-0 sm:divide-x divide-gray-200 text-center">
<% [
[ "Invoice Number", invoice.number ],
[ "Invoice Date", invoice.date&.strftime("%-d %b, %Y") ],
[ "Your Reference", invoice.reference ],
[ "Client ID", invoice.client_id ]
].each do |label, value| %>
[ "Invoice Number", :number, invoice.number, "R-1024" ],
[ "Invoice Date", :date, invoice.date&.strftime("%-d %b, %Y"), "" ],
[ "Your Reference", :reference, invoice.reference, "" ],
[ "Client ID", :client_id, invoice.client_id, "" ]
].each do |label, key, value, placeholder| %>
<div class="px-3 py-3">
<p class="text-xs font-bold text-gray-600"><%= label %></p>
<p class="text-sm mt-1"><%= value.presence || " " %></p>
<% if editable %>
<input type="text" name="invoice[<%= key %>]" value="<%= entered[key].presence || value %>" placeholder="<%= placeholder %>"
class="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm text-center focus:border-gray-400 focus:outline-none focus:ring-0 print:border-gray-200">
<% else %>
<p class="text-sm mt-1"><%= value.presence || " " %></p>
<% end %>
</div>
<% end %>
</div>
Expand All @@ -65,22 +91,51 @@
</thead>
<tbody>
<% invoice.line_items.each do |item| %>
<tr class="border-b border-gray-100">
<td class="py-2 pr-3 whitespace-nowrap align-top"><%= item.date&.strftime("%m/%d/%y") %></td>
<td class="py-2 pr-3 align-top">
<%= item.description %>
<% if item.details.any? %>
<ul class="mt-1 text-xs text-gray-500 leading-relaxed">
<% item.details.each do |detail| %>
<li><%= detail %></li>
<% end %>
</ul>
<% end %>
</td>
<td class="py-2 px-3 text-right tabular-nums align-top"><%= item.quantity %></td>
<td class="py-2 px-3 text-right tabular-nums align-top whitespace-nowrap"><%= dollars_from_cents(item.unit_price_cents) %></td>
<td class="py-2 pl-3 text-right tabular-nums align-top whitespace-nowrap"><%= dollars_from_cents(item.amount_cents) %></td>
</tr>
<% if editable %>
<tr class="border-b border-gray-100">
<td class="py-2 pr-3 align-top">
<input type="text" name="invoice[line_item][date]" value="<%= line_entered[:date] %>" placeholder="MM/DD/YY"
class="w-24 rounded-md border border-gray-200 px-2 py-1 text-sm focus:border-gray-400 focus:outline-none focus:ring-0 print:border-gray-200">
</td>
<td class="py-2 pr-3 align-top">
<input type="text" name="invoice[line_item][description]" value="<%= line_entered[:description].presence || item.description %>"
class="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:border-gray-400 focus:outline-none focus:ring-0 print:border-gray-200">
</td>
<td class="py-2 px-3 align-top">
<input type="number" min="0" step="1" name="invoice[line_item][quantity]" value="<%= line_entered[:quantity].presence || item.quantity %>"
data-invoice-editor-target="quantity" data-action="invoice-editor#recompute"
class="w-16 ml-auto block rounded-md border border-gray-200 px-2 py-1 text-sm text-right tabular-nums focus:border-gray-400 focus:outline-none focus:ring-0 print:border-gray-200">
</td>
<td class="py-2 px-3 align-top">
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400">$</span>
<input type="text" inputmode="decimal" name="invoice[line_item][unit_price]" value="<%= line_entered[:unit_price].presence || item.unit_price_field_value %>"
data-invoice-editor-target="unitPrice" data-action="invoice-editor#recompute"
class="w-24 rounded-md border border-gray-200 px-2 py-1 text-sm text-right tabular-nums focus:border-gray-400 focus:outline-none focus:ring-0 print:border-gray-200">
</div>
</td>
<td class="py-2 pl-3 text-right tabular-nums align-top whitespace-nowrap pt-3">
<span data-invoice-editor-target="lineAmount"><%= dollars_from_cents(item.amount_cents) %></span>
</td>
</tr>
<% else %>
<tr class="border-b border-gray-100">
<td class="py-2 pr-3 whitespace-nowrap align-top"><%= item.date&.strftime("%m/%d/%y") %></td>
<td class="py-2 pr-3 align-top">
<%= item.description %>
<% if item.details.any? %>
<ul class="mt-1 text-xs text-gray-500 leading-relaxed">
<% item.details.each do |detail| %>
<li><%= detail %></li>
<% end %>
</ul>
<% end %>
</td>
<td class="py-2 px-3 text-right tabular-nums align-top"><%= item.quantity %></td>
<td class="py-2 px-3 text-right tabular-nums align-top whitespace-nowrap"><%= dollars_from_cents(item.unit_price_cents) %></td>
<td class="py-2 pl-3 text-right tabular-nums align-top whitespace-nowrap"><%= dollars_from_cents(item.amount_cents) %></td>
</tr>
<% end %>
<% end %>
</tbody>
<%# With nothing applied (the blank template and bulk-payment invoices) the
Expand All @@ -92,13 +147,21 @@
<td colspan="3"></td>
<td class="pt-4 text-right text-sm font-bold">Total</td>
<td class="pt-4 text-right">
<span class="inline-block rounded-md border border-gray-400 px-3 py-1 text-sm font-bold tabular-nums whitespace-nowrap"><%= dollars_from_cents(invoice.total_cents) %></span>
<span class="inline-block rounded-md border border-gray-400 px-3 py-1 text-sm font-bold tabular-nums whitespace-nowrap"<%= %( data-invoice-editor-target="total").html_safe if editable %>><%= dollars_from_cents(invoice.total_cents) %></span>
</td>
</tr>
</tfoot>
<% end %>
</table>

<%# Notes: who the registration fees cover. Editable template only. %>
<% if editable %>
<div class="mt-6">
<label class="text-xs font-bold text-gray-700">Names included in registration fees</label>
<textarea name="invoice[names]" rows="3" class="<%= field_class %> mt-1" placeholder="One name per line…"><%= entered[:names] %></textarea>
</div>
<% end %>

<%# Payments and credits already applied, and the balance the payer still owes. %>
<% if invoice.amount_applied_cents.positive? %>
<div class="mt-8">
Expand Down Expand Up @@ -146,3 +209,6 @@
<p class="font-bold mt-1">Thank you.</p>
</footer>
</article>
<% if editable %>
</form>
<% end %>
Loading
Loading