Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_earnings.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_earnings.cgi

added on local at 2026-07-11 18:36:53

Added
+459
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 9d9c448f92a2
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# Earnings & Expenses (super-admin only)
4#
5# Income side reads paid invoices in the chosen date window.
6# Expenses side is a manually-maintained ledger (CRUD inline).
7# Date range UI reuses MODS::AnonPaths::parse_range_params for parity
8# with admin_funnels.cgi: presets (this month default), custom from/to.
9#
10# Quick math at the top: Revenue, Expenses, Net, Net margin %.
11#======================================================================
12use strict;
13use warnings;
14
15use lib '/var/www/vhosts/3dshawn.com/abforge.3dshawn.com';
16use CGI;
17use MODS::DBConnect;
18use MODS::Login;
19use MODS::ABForge::Config;
20use MODS::ABForge::Wrapper;
21use MODS::AnonPaths;
22
23my $q = CGI->new;
24my $form = $q->Vars;
25my $auth = MODS::Login->new;
26my $wrap = MODS::ABForge::Wrapper->new;
27my $db = MODS::DBConnect->new;
28my $cfg = MODS::ABForge::Config->new;
29my $DB = $cfg->settings('database_name');
30
31my $u = $auth->login_verify();
32unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
33my $sa_dbh = $db->db_connect();
34my $sa_uid = $u->{user_id}; $sa_uid =~ s/[^0-9]//g if defined $sa_uid;
35my $sa_row = $sa_uid ? $db->db_readwrite($sa_dbh, "SELECT is_super_admin FROM `${DB}`.users WHERE id='$sa_uid' LIMIT 1", $ENV{SCRIPT_NAME}, __LINE__) : undef;
36$db->db_disconnect($sa_dbh);
37unless ($sa_row && $sa_row->{is_super_admin}) {
38 print "Status: 403 Forbidden\nContent-Type: text/plain\n\nSuper-admin only.\n"; exit;
39}
40
41my $dbh = $db->db_connect();
42
43# Helpers ------------------------------------------------------------
44sub safe_int { my $v = shift // ''; $v =~ s/[^0-9\-]//g; return $v ? $v + 0 : 0; }
45sub safe_cents {
46 my $v = shift // '';
47 $v =~ s/[^0-9.\-]//g;
48 return int($v * 100 + ($v < 0 ? -0.5 : 0.5));
49}
50sub safe_date { my $v = shift // ''; return ($v =~ /^(\d{4}-\d{2}-\d{2})$/) ? $1 : ''; }
51sub sql_q { my $s = shift // ''; $s =~ s/\\/\\\\/g; $s =~ s/'/\\'/g; return $s; }
52sub h_esc {
53 my $s = shift // ''; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; return $s;
54}
55# ---- Viewer-local timestamp wrapper ---------------------------------
56sub _ern_ts_wrap {
57 my ($human, $epoch, $fmt) = @_;
58 $fmt = 'datetime' unless defined $fmt && length $fmt;
59 return '' unless defined($human) && length($human);
60 my $esc = h_esc($human);
61 return $esc unless $epoch && $epoch =~ /^\d+$/;
62 return qq~<span class="ts" data-ts="$epoch" data-fmt="$fmt">$esc</span>~;
63}
64sub fmt_money {
65 my ($cents) = @_; $cents = int($cents || 0);
66 my $neg = $cents < 0 ? '-' : ''; $cents = abs($cents);
67 my $whole = int($cents / 100); my $frac = sprintf('%02d', $cents % 100);
68 1 while $whole =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
69 return "$neg\$$whole.$frac";
70}
71sub fmt_n { my $n = int(shift // 0); 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/; return $n; }
72sub silenced_query {
73 my ($sql) = @_;
74 local *OLDOUT;
75 open(OLDOUT, '>&', \*STDOUT) or do {};
76 open(STDOUT, '>', '/dev/null') or do {};
77 my @rows = eval { $db->db_readwrite_multiple($dbh, $sql, $ENV{SCRIPT_NAME} || 'admin_earnings', __LINE__) };
78 @rows = () unless @rows;
79 open(STDOUT, '>&', \*OLDOUT) or do {};
80 return @rows;
81}
82sub silenced_exec {
83 my ($sql) = @_;
84 local *OLDOUT;
85 open(OLDOUT, '>&', \*STDOUT) or do {};
86 open(STDOUT, '>', '/dev/null') or do {};
87 eval { $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME} || 'admin_earnings', __LINE__) };
88 open(STDOUT, '>&', \*OLDOUT) or do {};
89}
90
91my $flash = '';
92
93# ----- Mutations: add / edit / delete expense -----------------------
94my $act = $form->{act} // '';
95my $uid = ($u->{user_id} || 0) + 0;
96
97if ($act eq 'add_expense' && $q->request_method eq 'POST') {
98 my $cat = sql_q(substr($form->{category} // 'other', 0, 80));
99 my $ven = sql_q(substr($form->{vendor} // '', 0, 160));
100 my $desc = sql_q(substr($form->{description} // '', 0, 500));
101 my $amt = safe_cents($form->{amount});
102 my $when = safe_date($form->{incurred_at}) || strftime_today();
103 my $url = sql_q(substr($form->{receipt_url} // '', 0, 500));
104 my $notes = sql_q(substr($form->{notes} // '', 0, 4000));
105 if ($amt) {
106 silenced_exec("INSERT INTO `${DB}`.expenses (category,vendor,description,amount_cents,currency,incurred_at,receipt_url,notes,created_by_user_id) VALUES ('$cat','$ven','$desc',$amt,'USD','$when','$url','$notes',$uid)");
107 $flash = "Expense added.";
108 } else {
109 $flash = "Amount required.";
110 }
111} elsif ($act eq 'edit_expense' && $q->request_method eq 'POST') {
112 my $eid = safe_int($form->{eid});
113 my $cat = sql_q(substr($form->{category} // 'other', 0, 80));
114 my $ven = sql_q(substr($form->{vendor} // '', 0, 160));
115 my $desc = sql_q(substr($form->{description} // '', 0, 500));
116 my $amt = safe_cents($form->{amount});
117 my $when = safe_date($form->{incurred_at});
118 my $url = sql_q(substr($form->{receipt_url} // '', 0, 500));
119 my $notes = sql_q(substr($form->{notes} // '', 0, 4000));
120 if ($eid && $amt && $when) {
121 silenced_exec("UPDATE `${DB}`.expenses SET category='$cat',vendor='$ven',description='$desc',amount_cents=$amt,incurred_at='$when',receipt_url='$url',notes='$notes' WHERE id=$eid");
122 $flash = "Expense updated.";
123 }
124} elsif ($act eq 'delete_expense' && $q->request_method eq 'POST') {
125 my $eid = safe_int($form->{eid});
126 if ($eid) {
127 silenced_exec("DELETE FROM `${DB}`.expenses WHERE id=$eid");
128 $flash = "Expense deleted.";
129 }
130}
131
132sub strftime_today {
133 my @t = localtime();
134 return sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]);
135}
136
137# ----- Date range ---------------------------------------------------
138my %qparams = %$form;
139# Default to this-month when no preset / from / to is set
140unless ($qparams{preset} || $qparams{from} || $qparams{to} || $qparams{days}) {
141 $qparams{preset} = 'this_month';
142}
143my ($range_opts, $preset_val, $range_from, $range_to) = MODS::AnonPaths::parse_range_params(\%qparams);
144my $is_range_mode = ($range_opts->{from} && $range_opts->{to}) ? 1 : 0;
145my $days = (ref($range_opts) eq 'HASH' && $range_opts->{days}) ? $range_opts->{days} : 30;
146
147# Resolve concrete from / to dates for SQL (even when preset=days)
148my ($from_iso, $to_iso);
149if ($is_range_mode) {
150 $from_iso = $range_opts->{from};
151 $to_iso = $range_opts->{to};
152} else {
153 require Time::Local;
154 my $now = time();
155 my $ago = $now - $days * 86400;
156 my @t1 = localtime($ago); my @t2 = localtime($now);
157 $from_iso = sprintf('%04d-%02d-%02d', $t1[5]+1900, $t1[4]+1, $t1[3]);
158 $to_iso = sprintf('%04d-%02d-%02d', $t2[5]+1900, $t2[4]+1, $t2[3]);
159}
160
161# ----- Revenue: paid invoices in the window -------------------------
162my $from_sql = "'$from_iso 00:00:00'";
163my $to_sql = "'$to_iso 23:59:59'";
164
165my @rev_total = silenced_query("SELECT COALESCE(SUM(amount_paid_cents),0) AS cents, COUNT(*) AS n FROM `${DB}`.invoices WHERE status='paid' AND paid_at BETWEEN $from_sql AND $to_sql");
166my $revenue_cents = ($rev_total[0] && $rev_total[0]->{cents}) ? $rev_total[0]->{cents} + 0 : 0;
167my $invoice_count = ($rev_total[0] && $rev_total[0]->{n}) ? $rev_total[0]->{n} + 0 : 0;
168
169my @invoices = silenced_query("SELECT i.id, i.invoice_number, i.company_id, i.status, i.amount_paid_cents, i.paid_at, UNIX_TIMESTAMP(i.paid_at) AS paid_at_epoch, c.name AS company_name FROM `${DB}`.invoices i LEFT JOIN `${DB}`.companies c ON c.id=i.company_id WHERE i.status='paid' AND i.paid_at BETWEEN $from_sql AND $to_sql ORDER BY i.paid_at DESC LIMIT 500");
170
171# ----- Expenses: ledger in the window -------------------------------
172my @exp_total = silenced_query("SELECT COALESCE(SUM(amount_cents),0) AS cents, COUNT(*) AS n FROM `${DB}`.expenses WHERE incurred_at BETWEEN '$from_iso' AND '$to_iso'");
173my $expense_cents = ($exp_total[0] && $exp_total[0]->{cents}) ? $exp_total[0]->{cents} + 0 : 0;
174my $expense_count = ($exp_total[0] && $exp_total[0]->{n}) ? $exp_total[0]->{n} + 0 : 0;
175
176my @expenses = silenced_query("SELECT id, category, vendor, description, amount_cents, incurred_at, UNIX_TIMESTAMP(incurred_at) AS incurred_at_epoch, receipt_url, notes FROM `${DB}`.expenses WHERE incurred_at BETWEEN '$from_iso' AND '$to_iso' ORDER BY incurred_at DESC, id DESC");
177
178my @cat_totals = silenced_query("SELECT category, COALESCE(SUM(amount_cents),0) AS cents, COUNT(*) AS n FROM `${DB}`.expenses WHERE incurred_at BETWEEN '$from_iso' AND '$to_iso' GROUP BY category ORDER BY cents DESC");
179
180# ----- Net + margin -------------------------------------------------
181my $net_cents = $revenue_cents - $expense_cents;
182my $net_margin = $revenue_cents ? sprintf('%.1f', 100 * $net_cents / $revenue_cents) : '0.0';
183
184$db->db_disconnect($dbh);
185
186# =====================================================================
187# Render
188# =====================================================================
189my $range_controls = MODS::AnonPaths::render_range_controls($preset_val, $range_from, $range_to);
190my $today = strftime_today();
191
192my $cat_rows = '';
193foreach my $c (@cat_totals) {
194 my $cat = h_esc($c->{category} // 'other');
195 my $cn = fmt_n($c->{n}); my $cm = fmt_money($c->{cents});
196 my $pct = $expense_cents ? sprintf('%.1f', 100 * $c->{cents} / $expense_cents) : '0.0';
197 $cat_rows .= qq~<div class="erng-cat-row"><span class="erng-cat-name">$cat</span><span class="erng-cat-bar"><span style="width:${pct}%"></span></span><span class="erng-cat-cnt">$cn</span><span class="erng-cat-amt">$cm</span></div>~;
198}
199$cat_rows ||= '<div class="erng-empty">No expenses logged in this window.</div>';
200
201my $inv_rows = '';
202foreach my $r (@invoices) {
203 my $num = h_esc($r->{invoice_number} // '');
204 my $cname = h_esc($r->{company_name} // '(unknown)');
205 my $when = _ern_ts_wrap($r->{paid_at}, $r->{paid_at_epoch}, 'date');
206 my $amt = fmt_money($r->{amount_paid_cents});
207 $inv_rows .= qq~<tr><td>$when</td><td>$num</td><td>$cname</td><td class="t-right">$amt</td></tr>~;
208}
209$inv_rows ||= qq~<tr><td colspan="4" class="erng-empty">No paid invoices in this window.</td></tr>~;
210
211my $exp_rows = '';
212foreach my $e (@expenses) {
213 my $eid = $e->{id} + 0;
214 my $cat = h_esc($e->{category} // 'other');
215 my $ven = h_esc($e->{vendor} // '');
216 my $desc = h_esc($e->{description} // '');
217 my $amt = fmt_money($e->{amount_cents});
218 my $when_raw = h_esc($e->{incurred_at} // '');
219 my $when = _ern_ts_wrap($e->{incurred_at}, $e->{incurred_at_epoch}, 'date');
220 my $url = h_esc($e->{receipt_url} // '');
221 my $notes = h_esc($e->{notes} // '');
222 my $rcpt = length($url) ? qq~<a href="$url" target="_blank" rel="noopener" class="erng-rcpt">receipt &#8599;</a>~ : '';
223 $exp_rows .= qq~
224 <tr class="erng-exp-row">
225 <td>$when</td>
226 <td><span class="erng-pill erng-pill-cat">$cat</span></td>
227 <td>$ven</td>
228 <td>$desc $rcpt</td>
229 <td class="t-right">$amt</td>
230 <td class="t-right">
231 <button type="button" class="erng-btn-ghost" onclick="ergEdit($eid)">Edit</button>
232 <form method="POST" action="/admin_earnings.cgi" style="display:inline" onsubmit="return confirm('Delete this expense?')">
233 <input type="hidden" name="act" value="delete_expense">
234 <input type="hidden" name="eid" value="$eid">
235 <input type="hidden" name="preset" value="$preset_val">
236 <input type="hidden" name="from" value="$range_from">
237 <input type="hidden" name="to" value="$range_to">
238 <button type="submit" class="erng-btn-danger">&times;</button>
239 </form>
240 </td>
241 </tr>
242 <tr class="erng-edit-row" id="erng-edit-$eid" style="display:none">
243 <td colspan="6">
244 <form method="POST" action="/admin_earnings.cgi" class="erng-edit-form">
245 <input type="hidden" name="act" value="edit_expense">
246 <input type="hidden" name="eid" value="$eid">
247 <input type="hidden" name="preset" value="$preset_val">
248 <input type="hidden" name="from" value="$range_from">
249 <input type="hidden" name="to" value="$range_to">
250 <label>Date <input type="date" name="incurred_at" value="$when_raw" required></label>
251 <label>Category <input type="text" name="category" value="$cat" required></label>
252 <label>Vendor <input type="text" name="vendor" value="$ven"></label>
253 <label class="grow">Description <input type="text" name="description" value="$desc"></label>
254 <label>Amount <input type="number" name="amount" step="0.01" value="${\ sprintf('%.2f', ($e->{amount_cents}||0)/100) }" required></label>
255 <label class="grow">Receipt URL <input type="url" name="receipt_url" value="$url"></label>
256 <label class="grow">Notes <input type="text" name="notes" value="$notes"></label>
257 <div class="row-end">
258 <button type="button" class="erng-btn-ghost" onclick="ergCancel($eid)">Cancel</button>
259 <button type="submit" class="erng-btn">Save</button>
260 </div>
261 </form>
262 </td>
263 </tr>~;
264}
265$exp_rows ||= qq~<tr><td colspan="6" class="erng-empty">No expenses logged in this window. Use the form above to add one.</td></tr>~;
266
267my $flash_html = '';
268if (length $flash) {
269 my $f = h_esc($flash);
270 $flash_html = qq~<div class="erng-flash">$f</div>~;
271}
272
273my $rev_disp = fmt_money($revenue_cents);
274my $exp_disp = fmt_money($expense_cents);
275my $net_disp = fmt_money($net_cents);
276my $net_color = ($net_cents >= 0) ? 'erng-pos' : 'erng-neg';
277
278my $css = qq~
279<style>
280.erng-hero { padding: 28px 28px 22px; background: linear-gradient(135deg, #1a0e08 0%, #0a0f17 100%); border-radius: 18px; margin-bottom: 22px; position: relative; overflow: hidden; }
281.erng-hero::after { content: ""; position: absolute; right: -60px; top: -60px; width: 320px; height: 320px; border-radius: 50%; background: radial-gradient(circle, rgba(74,222,128,0.22), transparent 70%); pointer-events: none; }
282.erng-hero h1 { margin: 0 0 6px; font-size: 28px; color: #fff; position: relative; }
283.erng-hero .sub { color: #8893a4; font-size: 13px; position: relative; max-width: 720px; line-height: 1.55; }
284.erng-toolbar { margin: 0 0 18px; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; display: flex; gap: 12px; flex-wrap: wrap; align-items: end; }
285.erng-toolbar .afnl-field { min-width: 140px; display: flex; flex-direction: column; gap: 4px; }
286.erng-toolbar .afnl-field-lbl { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
287.erng-toolbar select, .erng-toolbar input { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 8px 10px; border-radius: 7px; font-size: 13px; }
288.erng-kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 22px; }
289.erng-kpi { position: relative; overflow: hidden; padding: 20px; background: linear-gradient(160deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 14px; }
290.erng-kpi::after { content: ""; position: absolute; width: 200px; height: 100px; border-radius: 50%; filter: blur(40px); right: -40px; top: -30px; opacity: 0.4; pointer-events: none; }
291.erng-kpi.k-rev::after { background: #22c55e; }
292.erng-kpi.k-exp::after { background: #ef4444; }
293.erng-kpi.k-net::after { background: #5aa9ff; }
294.erng-kpi.k-mar::after { background: #c4a3f0; }
295.erng-kpi .lbl { font-size: 11px; color: #8893a4; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; position: relative; }
296.erng-kpi .val { font-size: 30px; font-weight: 800; color: #fff; margin-top: 6px; position: relative; }
297.erng-kpi .sub { font-size: 12px; color: #7a8392; margin-top: 4px; position: relative; }
298.erng-pos { color: #4ade80 !important; }
299.erng-neg { color: #f87171 !important; }
300.erng-panel { background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 14px; padding: 20px 22px; margin-bottom: 22px; }
301.erng-panel-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; gap: 16px; flex-wrap: wrap; }
302.erng-panel-title { font-size: 16px; font-weight: 700; color: #fff; }
303.erng-panel-sub { font-size: 12px; color: #8893a4; }
304.erng-flash { padding: 12px 16px; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.36); color: #4ade80; border-radius: 8px; margin-bottom: 18px; font-size: 13px; font-weight: 600; }
305table.erng-table { width: 100%; border-collapse: collapse; font-size: 13px; }
306table.erng-table th, table.erng-table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.05); color: #cfd6e0; }
307table.erng-table th { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; border-bottom: 1px solid rgba(255,255,255,0.08); }
308.t-right { text-align: right !important; }
309.erng-pill { display: inline-block; padding: 3px 9px; border-radius: 10px; font-size: 11px; font-weight: 700; background: rgba(196,163,240,0.18); color: #c4a3f0; text-transform: uppercase; letter-spacing: 0.05em; }
310.erng-rcpt { color: #5aa9ff; margin-left: 6px; font-size: 11px; }
311.erng-btn { padding: 7px 14px; background: #5aa9ff; color: #0a0f17; border: 0; border-radius: 7px; font-weight: 700; font-size: 13px; cursor: pointer; }
312.erng-btn:hover { background: #7cc1ff; }
313.erng-btn-ghost { padding: 5px 10px; background: rgba(255,255,255,0.06); color: #cfd6e0; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; font-size: 12px; cursor: pointer; }
314.erng-btn-ghost:hover { background: rgba(255,255,255,0.1); }
315.erng-btn-danger { padding: 5px 10px; background: rgba(239,68,68,0.18); color: #f87171; border: 1px solid rgba(239,68,68,0.4); border-radius: 6px; font-size: 14px; cursor: pointer; font-weight: 700; }
316.erng-btn-danger:hover { background: rgba(239,68,68,0.3); }
317.erng-add-form { display: grid; grid-template-columns: 130px 140px 1fr 1fr 130px 1fr auto; gap: 10px 12px; align-items: end; }
318.erng-add-form label { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; display: flex; flex-direction: column; gap: 4px; }
319.erng-add-form input, .erng-add-form select { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 8px 10px; border-radius: 7px; font-size: 13px; font-weight: 400; text-transform: none; letter-spacing: normal; }
320.erng-add-form .row-end { display: flex; gap: 8px; align-items: end; }
321.erng-edit-form { display: flex; flex-wrap: wrap; gap: 10px 12px; padding: 12px 14px; background: rgba(90,169,255,0.06); border-radius: 8px; }
322.erng-edit-form label { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; display: flex; flex-direction: column; gap: 4px; }
323.erng-edit-form label.grow { flex: 1 1 220px; }
324.erng-edit-form input { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 7px 9px; border-radius: 6px; font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: normal; }
325.erng-edit-form .row-end { display: flex; gap: 8px; align-items: end; margin-left: auto; }
326.erng-cat-grid { display: flex; flex-direction: column; gap: 6px; }
327.erng-cat-row { display: grid; grid-template-columns: 140px 1fr 60px 100px; gap: 12px; align-items: center; padding: 6px 0; font-size: 13px; color: #cfd6e0; }
328.erng-cat-name { font-weight: 600; color: #fff; text-transform: capitalize; }
329.erng-cat-bar { background: rgba(255,255,255,0.05); border-radius: 4px; height: 8px; overflow: hidden; }
330.erng-cat-bar span { display: block; height: 100%; background: linear-gradient(90deg, #ef4444, #f87171); border-radius: 4px; }
331.erng-cat-cnt { font-size: 11px; color: #7a8392; text-align: right; }
332.erng-cat-amt { font-weight: 700; color: #f87171; text-align: right; }
333.erng-empty { text-align: center; padding: 24px; color: #7a8392; font-size: 13px; font-style: italic; }
334</style>
335~;
336
337my $body = qq~
338$css
339
340<div class="erng-hero">
341 <h1>Earnings &amp; Expenses</h1>
342 <p class="sub">Super-admin financial summary. Income comes from paid invoices in the window. Expenses are a manual ledger you maintain here. Numbers are gross; check with your accountant before filing.</p>
343</div>
344
345$flash_html
346
347<form method="GET" action="/admin_earnings.cgi" class="erng-toolbar">
348 $range_controls
349</form>
350
351<div class="erng-kpi-grid">
352 <div class="erng-kpi k-rev">
353 <div class="lbl">Revenue</div>
354 <div class="val erng-pos">$rev_disp</div>
355 <div class="sub">${\ fmt_n($invoice_count) } paid invoice${\ ($invoice_count == 1 ? '' : 's') }</div>
356 </div>
357 <div class="erng-kpi k-exp">
358 <div class="lbl">Expenses</div>
359 <div class="val erng-neg">$exp_disp</div>
360 <div class="sub">${\ fmt_n($expense_count) } expense${\ ($expense_count == 1 ? '' : 's') } logged</div>
361 </div>
362 <div class="erng-kpi k-net">
363 <div class="lbl">Net</div>
364 <div class="val $net_color">$net_disp</div>
365 <div class="sub">Revenue minus expenses</div>
366 </div>
367 <div class="erng-kpi k-mar">
368 <div class="lbl">Net margin</div>
369 <div class="val">$net_margin%</div>
370 <div class="sub">Net &divide; revenue</div>
371 </div>
372</div>
373
374<div class="erng-panel">
375 <div class="erng-panel-head">
376 <div>
377 <div class="erng-panel-title">Add expense</div>
378 <div class="erng-panel-sub">Logged expenses count against this window's net.</div>
379 </div>
380 </div>
381 <form method="POST" action="/admin_earnings.cgi" class="erng-add-form">
382 <input type="hidden" name="act" value="add_expense">
383 <input type="hidden" name="preset" value="$preset_val">
384 <input type="hidden" name="from" value="$range_from">
385 <input type="hidden" name="to" value="$range_to">
386 <label>Date <input type="date" name="incurred_at" value="$today" required></label>
387 <label>Category <input type="text" name="category" list="erng-cats" value="other" required></label>
388 <label>Vendor <input type="text" name="vendor" placeholder="e.g. AWS, Plesk"></label>
389 <label>Description <input type="text" name="description" placeholder="What was this for?"></label>
390 <label>Amount (\$) <input type="number" name="amount" step="0.01" placeholder="0.00" required></label>
391 <label>Receipt URL <input type="url" name="receipt_url" placeholder="Optional link to receipt"></label>
392 <div class="row-end"><button type="submit" class="erng-btn">Add</button></div>
393 </form>
394 <datalist id="erng-cats">
395 <option value="hosting">
396 <option value="software">
397 <option value="marketing">
398 <option value="contractor">
399 <option value="payroll">
400 <option value="fees">
401 <option value="advertising">
402 <option value="domains">
403 <option value="travel">
404 <option value="office">
405 <option value="legal">
406 <option value="taxes">
407 <option value="other">
408 </datalist>
409</div>
410
411<div class="erng-panel">
412 <div class="erng-panel-head">
413 <div>
414 <div class="erng-panel-title">Expenses by category</div>
415 <div class="erng-panel-sub">Where the money went this window.</div>
416 </div>
417 </div>
418 <div class="erng-cat-grid">$cat_rows</div>
419</div>
420
421<div class="erng-panel">
422 <div class="erng-panel-head">
423 <div>
424 <div class="erng-panel-title">Expense ledger</div>
425 <div class="erng-panel-sub">Edit or delete any row. Newest first.</div>
426 </div>
427 </div>
428 <table class="erng-table">
429 <thead><tr><th>Date</th><th>Category</th><th>Vendor</th><th>Description</th><th class="t-right">Amount</th><th class="t-right">&nbsp;</th></tr></thead>
430 <tbody>$exp_rows</tbody>
431 </table>
432</div>
433
434<div class="erng-panel">
435 <div class="erng-panel-head">
436 <div>
437 <div class="erng-panel-title">Paid invoices</div>
438 <div class="erng-panel-sub">Every invoice in the window with status=paid.</div>
439 </div>
440 </div>
441 <table class="erng-table">
442 <thead><tr><th>Paid at</th><th>Invoice #</th><th>Company</th><th class="t-right">Amount</th></tr></thead>
443 <tbody>$inv_rows</tbody>
444 </table>
445</div>
446
447<script>
448function ergEdit(eid){ var r=document.getElementById('erng-edit-'+eid); if(r) r.style.display='table-row'; }
449function ergCancel(eid){ var r=document.getElementById('erng-edit-'+eid); if(r) r.style.display='none'; }
450</script>
451~;
452
453print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
454$wrap->render({
455 userinfo => $u,
456 page_key => 'admin_earnings',
457 title => 'Earnings & Expenses',
458 body => $body,
459});