Add income and expenses chart in SVG
This commit is contained in:
parent
d1b978054b
commit
39b0b801b2
110
pkg/dashboard.go
110
pkg/dashboard.go
|
@ -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>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -48,4 +48,5 @@
|
||||||
<dd>{{ formatPriceSpan .NetIncome }}</dd>
|
<dd>{{ formatPriceSpan .NetIncome }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{{ .Chart }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
Loading…
Reference in New Issue