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

O Operator
Diff

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

added on local at 2026-07-11 18:33:26

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