From c2020bf90346bc229c767d9511a585b6a83e52c4 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 08:23:14 +0400 Subject: [PATCH] admin: dashboard instead of the invoice list /admin redirected to the invoice list, which stopped making sense once the admin also ran the bingo. Make it a dashboard with one tile per area: invoice totals, outstanding amount per currency, overdue count and the five latest invoices; the bingo score, reserve size, latest incident and card cells still missing a photo. A shared nav links the sections. Tiles are per area so they can follow user permissions later. Assisted-by: Claude:opus-5 --- CLAUDE.md | 2 +- asxpio.rb | 22 ++++++++++++++- public/style.css | 30 +++++++++++++++++++++ test/bingo_test.rb | 24 +++++++++++++++++ views/admin/_nav.erb | 5 ++++ views/admin/bingo/index.erb | 4 +-- views/admin/dashboard.erb | 49 ++++++++++++++++++++++++++++++++++ views/admin/invoices/index.erb | 3 ++- 8 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 views/admin/_nav.erb create mode 100644 views/admin/dashboard.erb diff --git a/CLAUDE.md b/CLAUDE.md index 7e782c5..a24a0c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ If a form is ever reinstated, note that Traefik's access log (`/opt/traefik/logs Admin (HTTP Basic via `ADMIN_USER`/`ADMIN_PASSWORD`): -- `GET /admin` → redirects to `/admin/invoices`. +- `GET /admin` → dashboard (`views/admin/dashboard.erb`): invoice counts/outstanding/overdue + recent five, bingo score/reserve/latest incident/missing photos. Shared admin nav in `views/admin/_nav.erb`. - `GET /admin/invoices` — list, newest first. - `GET /admin/invoices/new` — create form. Dynamic line items via `public/admin-invoice-form.js`. - `POST /admin/invoices` — validate → allocate number → render PDF → upload to MinIO → persist row. diff --git a/asxpio.rb b/asxpio.rb index 94c9e04..cfd1478 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -130,8 +130,28 @@ class AsxpioWeb < Sinatra::Base halt 503, 'Invoicing not configured (DATABASE_URL missing).' unless defined?(Invoice) end + # Overview of every admin area; each tile links into its section. get '/admin' do - redirect '/admin/invoices' + @page_title = 'Dashboard · admin' + @noindex = true + unpaid = Invoice.where(paid_at: nil).all + @inv = { + count: Invoice.count, + unpaid: unpaid, + overdue: unpaid.count { |i| i.due_on < Date.today }, + unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) }, + recent: Invoice.order(Sequel.desc(:created_at)).limit(5).all + } + board = Bingo::Board.load + @bingo = { + cells: board.cells.size, + hit: board.hit.count(true), + lines: board.wins.size, + reserve: [Bingo::Datacenter.count - board.cells.size, 0].max, + last_incident: board.incidents.first, + no_photo: board.cells.reject(&:photo_key) + } + erb :'admin/dashboard' end get '/admin/invoices' do diff --git a/public/style.css b/public/style.css index 5515617..5144a9f 100644 --- a/public/style.css +++ b/public/style.css @@ -346,6 +346,36 @@ a:hover { font-size: 0.9rem; } +/* Admin nav + dashboard */ +.admin-nav { + display: flex; + gap: 16px; + justify-content: center; + margin-bottom: 24px; + font-family: var(--font-mono); + font-size: 0.85rem; +} +.admin-nav a { color: var(--text-dim); text-decoration: none; } +.admin-nav a.active, +.admin-nav a:hover { color: var(--text-bright); } +.dash { display: grid; gap: 24px; } +.dash-tile h2 a { color: inherit; text-decoration: none; } +.stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 12px 0 16px; +} +.stats div { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 12px; + text-align: center; +} +.stats b { display: block; font-size: 1.4rem; color: var(--text-bright); font-variant-numeric: tabular-nums; } +.stats span { font-size: 0.75rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; } + /* Invoices */ .invoice-list { width: 100%; diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 853978c..bcc542a 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -253,6 +253,30 @@ class BingoTest < Minitest::Test assert_equal 401, last_response.status end + def test_admin_dashboard + @app = AsxpioWeb + hit!(2) + DB.connection[:invoices].delete + inv = Invoice.build(client_name: 'ACME', client_email: 'b@example.com', currency: 'EUR', gel_rate: '3.05', + due_on: (Date.today - 1).to_s, + items: [{ 'description' => 'work', 'qty' => '2', 'unit_price' => '50' }]) + inv.pdf_key = "invoices/#{inv.number}-#{inv.uuid}.pdf" + inv.save_changes + get '/admin', {}, admin_env + assert last_response.ok?, last_response.status.to_s + body = last_response.body + assert_includes body, '

Dashboard

' + assert_includes body, '1/25' # crossed out + assert_includes body, 'Авария 2' # latest incident + assert_includes body, 'href="/admin/invoices/new"' + assert_includes body, 'class="active">dashboard' + assert_includes body, inv.number + assert_includes body, 'EUR 100.00' # outstanding total + assert_match %r{1overdue}, body + ensure + DB.connection[:invoices].delete + end + def test_admin_records_and_deletes_incident @app = AsxpioWeb get '/admin/bingo', {}, admin_env diff --git a/views/admin/_nav.erb b/views/admin/_nav.erb new file mode 100644 index 0000000..b9a38f9 --- /dev/null +++ b/views/admin/_nav.erb @@ -0,0 +1,5 @@ + diff --git a/views/admin/bingo/index.erb b/views/admin/bingo/index.erb index 04c76e2..5d9ce87 100644 --- a/views/admin/bingo/index.erb +++ b/views/admin/bingo/index.erb @@ -1,8 +1,8 @@ +<%== erb :'admin/_nav', layout: false %>

Bingo

- invoices - <% if bingo_public_url %> · public card ↗<% end %> + <% if bingo_public_url %>public card ↗<% end %>

diff --git a/views/admin/dashboard.erb b/views/admin/dashboard.erb new file mode 100644 index 0000000..9f352e4 --- /dev/null +++ b/views/admin/dashboard.erb @@ -0,0 +1,49 @@ +<%== erb :'admin/_nav', layout: false %> +
+

Dashboard

+
+ +
+
+

Invoices

+
+
<%= @inv[:count] %>total
+
<%= @inv[:unpaid].size %>unpaid
+
<%= @inv[:overdue] %>overdue
+
+ <% if @inv[:unpaid_totals].any? %> +

Outstanding: <%= @inv[:unpaid_totals].map { |cur, sum| "#{cur} #{'%.2f' % sum}" }.join(' · ') %>

+ <% end %> + <% if @inv[:recent].any? %> + + + <% @inv[:recent].each do |inv| %> + + + + + + + <% end %> + +
<%= inv.number %><%= inv.client_name %><%= inv.currency %> <%= '%.2f' % inv.total %><%= inv.status %>
+ <% end %> +

+ New invoice · all invoices

+
+ +
+

Bingo

+
+
<%= @bingo[:hit] %>/<%= @bingo[:cells] %>crossed out
+
<%= @bingo[:lines] %>lines
+
<%= @bingo[:reserve] %>in reserve
+
+ <% if (last = @bingo[:last_incident]) %> +

Latest: <%= last.occurred_on %> <%= Bingo::ICONS[last.kind] %> <%= last.datacenter.name_ru %> — <%= last.title_ru %>

+ <% end %> + <% if @bingo[:no_photo].any? %> +

No photo on the card: <%= @bingo[:no_photo].map(&:name_ru).join(', ') %>

+ <% end %> +

manage<% if bingo_public_url %> · public card ↗<% end %>

+
+
diff --git a/views/admin/invoices/index.erb b/views/admin/invoices/index.erb index 51dd766..5ec6d52 100644 --- a/views/admin/invoices/index.erb +++ b/views/admin/invoices/index.erb @@ -1,6 +1,7 @@ +<%== erb :'admin/_nav', layout: false %>

Invoices

-

+ New invoice · bingo

+

+ New invoice

<% if @invoices.empty? %>