Add income and expenses chart in SVG

This commit is contained in:
jordi fita mas 2023-05-20 15:53:59 +02:00
parent d1b978054b
commit 39b0b801b2
3 changed files with 131 additions and 3 deletions

View File

@ -1,9 +1,13 @@
package pkg package pkg
import ( import (
"context"
"fmt" "fmt"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"html/template"
"math"
"net/http" "net/http"
"strings"
) )
const ( const (
@ -22,6 +26,7 @@ type DashboardPage struct {
VAT string VAT string
IRPF string IRPF string
NetIncome string NetIncome string
Chart template.HTML
Filters *dashboardFilterForm Filters *dashboardFilterForm
} }
@ -53,6 +58,10 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
filters.Period.Selected = MonthPeriod filters.Period.Selected = MonthPeriod
} }
conn := getConn(r) conn := getConn(r)
dashboard := &DashboardPage{
Filters: filters,
Chart: buildDashboardChart(r.Context(), conn, company, periodStart, periodEnd, filters.Period.Selected),
}
rows := conn.MustQuery(r.Context(), fmt.Sprintf(` rows := conn.MustQuery(r.Context(), fmt.Sprintf(`
select to_price(0, decimal_digits) as sales select to_price(0, decimal_digits) as sales
, to_price(coalesce(invoice.total, 0), decimal_digits) as income , to_price(coalesce(invoice.total, 0), decimal_digits) as income
@ -101,9 +110,6 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
`, periodStart, periodEnd), company.Id) `, periodStart, periodEnd), company.Id)
defer rows.Close() defer rows.Close()
dashboard := &DashboardPage{
Filters: filters,
}
for rows.Next() { for rows.Next() {
if err := rows.Scan(&dashboard.Sales, &dashboard.Income, &dashboard.Expenses, &dashboard.VAT, &dashboard.IRPF, &dashboard.NetIncome); err != nil { if err := rows.Scan(&dashboard.Sales, &dashboard.Income, &dashboard.Expenses, &dashboard.VAT, &dashboard.IRPF, &dashboard.NetIncome); err != nil {
panic(err) panic(err)
@ -166,3 +172,101 @@ func (form *dashboardFilterForm) Parse(r *http.Request) error {
form.Period.FillValue(r) form.Period.FillValue(r)
return nil return nil
} }
func buildDashboardChart(ctx context.Context, conn *Conn, company *Company, periodStart string, periodEnd string, selectedPeriod string) template.HTML {
group := "yyyymmdd"
switch selectedPeriod {
case YearPeriod, YesteryearPeriod:
group = "yyyymm"
}
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select date
, 0 as sales
, coalesce(invoice.total, 0) as income
, coalesce(expense.total, 0) as expenses
from (
select to_char(date.invoice_date, '%[3]s')::integer as date
, sum(total)::integer as total
from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date)
left join invoice on invoice.invoice_date = date.invoice_date and company_id = $1
left join invoice_amount using (invoice_id)
group by date
) as invoice
left join (
select to_char(date.invoice_date, '%[3]s')::integer as date
, sum(amount)::integer as total
from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date)
left join expense on expense.invoice_date = date.invoice_date and company_id = 1
group by date
) as expense using (date)
order by date
`, periodStart, periodEnd, group), company.Id)
defer rows.Close()
type value struct {
date int
sales float64
income float64
expenses float64
}
var values []value
var max = 0.
for rows.Next() {
var v value
var sales int
var income int
var expenses int
if err := rows.Scan(&v.date, &sales, &income, &expenses); err != nil {
panic(err)
}
v.sales = float64(sales)
v.income = float64(income)
v.expenses = float64(expenses)
values = append(values, v)
max = math.Max(v.sales, max)
max = math.Max(v.income, max)
max = math.Max(v.expenses, max)
}
if rows.Err() != nil {
panic(rows.Err())
}
width := 1024.
height := 300.
dataPoints := float64(len(values))
var sb strings.Builder
sb.WriteString(fmt.Sprintf("<svg id='income-chart' viewBox='-10 -10 %d %d'>", int(width)+20, int(height)+20))
sb.WriteString("<polyline points='")
for i, v := range values {
sb.WriteString(fmt.Sprintf(" %f,%f", float64(i)/dataPoints*width, height-v.sales/max*height))
}
sb.WriteString("'/>")
sb.WriteString("<polyline points='")
for i, v := range values {
sb.WriteString(fmt.Sprintf(" %f,%f", float64(i)/dataPoints*width, height-v.income/max*height))
}
sb.WriteString("'/>")
sb.WriteString("<polyline points='")
for i, v := range values {
sb.WriteString(fmt.Sprintf(" %f,%f", float64(i)/dataPoints*width, height-v.expenses/max*height))
}
sb.WriteString("'/>")
for i, v := range values {
sb.WriteString(fmt.Sprintf("<circle cx='%f' cy='%f' r='4'/>", float64(i)/dataPoints*width, height-v.sales/max*height))
sb.WriteString(fmt.Sprintf("<circle cx='%f' cy='%f' r='4'/>", float64(i)/dataPoints*width, height-v.income/max*height))
sb.WriteString(fmt.Sprintf("<circle cx='%f' cy='%f' r='4'/>", float64(i)/dataPoints*width, height-v.expenses/max*height))
}
sb.WriteString("</svg>")
return template.HTML(sb.String())
return template.HTML(`
<svg id="income-chart" viewBox="0 0 500 200">
<polyline points="0,120 20,60 40,80 60,20"/>
<polyline points="10,130 30,70 50,90 70,30"/>
<polyline points="20,140 40,80 60,100 80,40"/>
<circle cx="0" cy="120" r="2"/>
<circle cx="20" cy="60" r="2"/>
<circle cx="40" cy="80" r="2"/>
<circle cx="60" cy="20" r="2"/>
</svg>
`)
}

View File

@ -900,6 +900,29 @@ div[x-data="snackbar"] div[role="alert"].enter.end, div[x-data="snackbar"] div[r
font-size: .66666em; font-size: .66666em;
} }
#income-chart polyline {
fill: none;
stroke-width: 5;
vector-effect: non-scaling-stroke;
}
#income-chart polyline:nth-of-type(1) {
stroke: var(--numerus--color--yellow);
}
#income-chart polyline:nth-of-type(2) {
stroke: var(--numerus--color--green);
}
#income-chart polyline:nth-of-type(3) {
stroke: var(--numerus--color--red);
}
#income-chart circle {
fill: var(--numerus--color--dark-gray);
vector-effect: non-scaling-stroke;
}
/* Remix Icon */ /* Remix Icon */
@font-face { @font-face {

View File

@ -48,4 +48,5 @@
<dd>{{ formatPriceSpan .NetIncome }}</dd> <dd>{{ formatPriceSpan .NetIncome }}</dd>
</div> </div>
</dl> </dl>
{{ .Chart }}
{{- end }} {{- end }}