Diff -- /var/www/vhosts/webstls.com/httpdocs/_admin_earnings_src.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/_admin_earnings_src.cgi

added on WebSTLs (webstls.com) at 2026-07-01 22:27:10

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