From 226ccb819266bf9902fa10e0c238becc24052c58 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Tue, 26 May 2026 17:54:40 +0300 Subject: [PATCH] invoicing: admin and public views Three admin templates: - index: invoice list with status badge and link to detail. - new: client + terms fields, dynamic line-item rows backed by admin-invoice-form.js (add/remove + re-index of items[N][...] names). - show: invoice detail, public URL to share, toggle-paid button. One public template, invoice_public.erb, shown at /i/:uuid: client name, number, total, status, and a single big download button to /i/:uuid/pdf. All admin pages are noindex; same for the public landing (it's a client-specific URL, not search-discoverable content). --- public/admin-invoice-form.js | 28 +++++++++++ views/admin/invoices/index.erb | 41 ++++++++++++++++ views/admin/invoices/new.erb | 88 ++++++++++++++++++++++++++++++++++ views/admin/invoices/show.erb | 43 +++++++++++++++++ views/invoice_public.erb | 24 ++++++++++ 5 files changed, 224 insertions(+) create mode 100644 public/admin-invoice-form.js create mode 100644 views/admin/invoices/index.erb create mode 100644 views/admin/invoices/new.erb create mode 100644 views/admin/invoices/show.erb create mode 100644 views/invoice_public.erb diff --git a/public/admin-invoice-form.js b/public/admin-invoice-form.js new file mode 100644 index 0000000..39b2f1c --- /dev/null +++ b/public/admin-invoice-form.js @@ -0,0 +1,28 @@ +(function () { + const tbody = document.querySelector('#items-table tbody'); + const addBtn = document.getElementById('add-row'); + + function reindex() { + Array.from(tbody.querySelectorAll('tr.item-row')).forEach((row, i) => { + row.querySelectorAll('input').forEach((input) => { + input.name = input.name.replace(/items\[\d+\]/, 'items[' + i + ']'); + }); + }); + } + + addBtn.addEventListener('click', function () { + const last = tbody.querySelector('tr.item-row:last-child'); + const clone = last.cloneNode(true); + clone.querySelectorAll('input').forEach((input) => { input.value = ''; }); + tbody.appendChild(clone); + reindex(); + }); + + tbody.addEventListener('click', function (e) { + if (!e.target.matches('.remove-row')) return; + const rows = tbody.querySelectorAll('tr.item-row'); + if (rows.length <= 1) return; + e.target.closest('tr.item-row').remove(); + reindex(); + }); +})(); diff --git a/views/admin/invoices/index.erb b/views/admin/invoices/index.erb new file mode 100644 index 0000000..08241c0 --- /dev/null +++ b/views/admin/invoices/index.erb @@ -0,0 +1,41 @@ +
+

Invoices

+

+ New invoice

+
+ +<% if @invoices.empty? %> +
+

No invoices yet. Create the first one.

+
+<% else %> +
+ + + + + + + + + + + + + <% @invoices.each do |inv| %> + + + + + + + + + <% end %> + +
NumberClientIssuedTotalStatus
<%= inv.number %><%= inv.client_name %><%= inv.issued_on.strftime('%Y-%m-%d') %><%= inv.currency %> <%= '%.2f' % inv.total %> + <%= inv.status %> + + view +
+
+<% end %> diff --git a/views/admin/invoices/new.erb b/views/admin/invoices/new.erb new file mode 100644 index 0000000..217a360 --- /dev/null +++ b/views/admin/invoices/new.erb @@ -0,0 +1,88 @@ +
+

New invoice

+

← all invoices

+
+ +<% if @form_errors && !@form_errors.empty? %> +
+ <% @form_errors.each_value do |msg| %> +
<%= msg %>
+ <% end %> +
+<% end %> + +
+
+ + +

Client

+
+ + +
+
+ + +
+
+ + +
+ +

Terms

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Line items

+ + + + + + + + + + + <% (@form_values[:items] || [{'description' => '', 'qty' => '', 'unit_price' => ''}]).each_with_index do |item, i| %> + + + + + + + <% end %> + +
DescriptionQtyUnit price
+ + +
+ + +
+ + +
+
+ + diff --git a/views/admin/invoices/show.erb b/views/admin/invoices/show.erb new file mode 100644 index 0000000..c3883b5 --- /dev/null +++ b/views/admin/invoices/show.erb @@ -0,0 +1,43 @@ +
+

<%= @invoice.number %>

+

← all invoices

+
+ +
+
+
Client
<%= @invoice.client_name %> <<%= @invoice.client_email %>>
+
Issued
<%= @invoice.issued_on.strftime('%Y-%m-%d') %>
+
Due
<%= @invoice.due_on.strftime('%Y-%m-%d') %>
+
Total
<%= @invoice.currency %> <%= '%.2f' % @invoice.total %><% unless @invoice.currency == 'GEL' %> · GEL <%= '%.2f' % @invoice.total_gel %><% end %>
+
Status
<%= @invoice.status %>
+ <% if @invoice.paid_at %> +
Paid at
<%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %>
+ <% end %> +
+
+ +
+

Actions

+

+ Public landing page +  ·  + Download PDF +

+

+ Share this URL with the client:
+ https://asxp.io/i/<%= @invoice.uuid %> +

+
+ + +
+
+ +<% unless @invoice.notes.to_s.strip.empty? %> +
+

Notes

+

<%= @invoice.notes %>

+
+<% end %> diff --git a/views/invoice_public.erb b/views/invoice_public.erb new file mode 100644 index 0000000..07f4ae4 --- /dev/null +++ b/views/invoice_public.erb @@ -0,0 +1,24 @@ +
+

Invoice <%= @invoice.number %>

+

from IE Sergei Poljanski

+
+ +
+
+
Billed to
<%= @invoice.client_name %>
+
Issued
<%= @invoice.issued_on.strftime('%Y-%m-%d') %>
+
Due
<%= @invoice.due_on.strftime('%Y-%m-%d') %>
+
Total
<%= @invoice.currency %> <%= '%.2f' % @invoice.total %>
+
Status
<%= @invoice.status %>
+
+
+ +
+

+ Download PDF +

+
+ +