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, '
- invoices - <% if bingo_public_url %> · public card ↗<% end %> + <% if bingo_public_url %>public card ↗<% end %>
Outstanding: <%= @inv[:unpaid_totals].map { |cur, sum| "#{cur} #{'%.2f' % sum}" }.join(' · ') %>
+ <% end %> + <% if @inv[:recent].any? %> +| <%= inv.number %> | +<%= inv.client_name %> | +<%= inv.currency %> <%= '%.2f' % inv.total %> | +<%= inv.status %> | +
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 %>
+