numerus/pkg/dashboard.go

290 lines
9.7 KiB
Go

package pkg
import (
"context"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
"math"
"net/http"
"strings"
"time"
)
const (
MonthPeriod = "month"
YestermonthPeriod = "yestermonth"
QuarterPeriod = "quarter"
YesterquarterPeriod = "yesterquarter"
YearPeriod = "year"
YesteryearPeriod = "yesteryear"
)
type DashboardPage struct {
Sales string
Income string
Expenses string
VAT string
IRPF string
NetIncome string
Chart template.HTML
Filters *dashboardFilterForm
}
func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
company := mustGetCompany(r)
locale := getLocale(r)
filters := newDashboardFilterForm(locale, company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
periodStart := "date_trunc('month', current_date)::date"
periodEnd := "current_date"
switch filters.Period.Selected {
case YestermonthPeriod:
periodStart = "date_trunc('month', current_date - interval '1 month')::date"
periodEnd = "(date_trunc('month', current_date) - interval '1 day')::date"
case QuarterPeriod:
periodStart = "date_trunc('quarter', current_date)::date"
case YesterquarterPeriod:
periodStart = "date_trunc('quarter', current_date - interval '3 months')::date"
periodEnd = "(date_trunc('quarter', current_date) - interval '1 day')::date"
case YearPeriod:
periodStart = "date_trunc('year', current_date)::date"
case YesteryearPeriod:
periodStart = "date_trunc('year', current_date - interval '1 year')::date"
periodEnd = "(date_trunc('year', current_date) - interval '1 day')::date"
case "":
filters.Period.Selected = MonthPeriod
}
conn := getConn(r)
dashboard := &DashboardPage{
Filters: filters,
Chart: buildDashboardChart(r.Context(), conn, locale, company, periodStart, periodEnd, filters.Period.Selected),
}
rows := conn.MustQuery(r.Context(), fmt.Sprintf(`
select to_price(0, decimal_digits) as sales
, to_price(coalesce(invoice.total, 0), decimal_digits) as income
, to_price(coalesce(expense.total, 0) + coalesce(expense_tax.vat, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as expenses
, to_price(coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0), decimal_digits) as vat
, to_price(coalesce(invoice_tax.irpf, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as irpf
, to_price(coalesce(invoice.total, 0) - coalesce(expense.total, 0) - (coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0)) + coalesce(expense_tax.irpf, 0), decimal_digits) as net_income
from company
left join (
select company_id, sum(total)::integer as total
from invoice
join invoice_amount using (invoice_id)
where invoice_date between %[1]s and %[2]s
group by company_id
) as invoice using (company_id)
left join (
select company_id, sum(amount)::integer as total
from expense
where invoice_date between %[1]s and %[2]s
group by company_id
) as expense using (company_id)
left join (
select invoice.company_id
, sum(case when tax_class.name = 'IVA' then invoice_tax_amount.amount else 0 end)::integer as vat
, sum(case when tax_class.name = 'IRPF' then invoice_tax_amount.amount else 0 end)::integer as irpf
from invoice
join invoice_tax_amount using (invoice_id)
join tax using (tax_id)
join tax_class using (tax_class_id)
where invoice_date between %[1]s and %[2]s
group by invoice.company_id
) as invoice_tax using (company_id)
left join (
select expense.company_id
, sum(case when tax_class.name = 'IVA' then expense_tax_amount.amount else 0 end)::integer as vat
, sum(case when tax_class.name = 'IRPF' then expense_tax_amount.amount else 0 end)::integer as irpf
from expense
join expense_tax_amount using (expense_id)
join tax using (tax_id)
join tax_class using (tax_class_id)
where invoice_date between %[1]s and %[2]s
group by expense.company_id
) as expense_tax using (company_id)
join currency using (currency_code)
where company_id = $1
`, periodStart, periodEnd), company.Id)
defer rows.Close()
for rows.Next() {
if err := rows.Scan(&dashboard.Sales, &dashboard.Income, &dashboard.Expenses, &dashboard.VAT, &dashboard.IRPF, &dashboard.NetIncome); err != nil {
panic(err)
}
}
if rows.Err() != nil {
panic(rows.Err())
}
mustRenderMainTemplate(w, r, "dashboard.gohtml", dashboard)
}
type dashboardFilterForm struct {
locale *Locale
company *Company
Period *RadioField
}
func newDashboardFilterForm(locale *Locale, company *Company) *dashboardFilterForm {
return &dashboardFilterForm{
locale: locale,
company: company,
Period: &RadioField{
Name: "period",
Selected: MonthPeriod,
Label: pgettext("input", "Period", locale),
Options: []*RadioOption{
{
Label: pgettext("period option", "Month", locale),
Value: MonthPeriod,
},
{
Label: pgettext("period option", "Previous month", locale),
Value: YestermonthPeriod,
},
{
Label: pgettext("period option", "Quarter", locale),
Value: QuarterPeriod,
},
{
Label: pgettext("period option", "Previous quarter", locale),
Value: YesterquarterPeriod,
},
{
Label: pgettext("period option", "Year", locale),
Value: YearPeriod,
},
{
Label: pgettext("period option", "Previous year", locale),
Value: YesteryearPeriod,
},
},
},
}
}
func (form *dashboardFilterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Period.FillValue(r)
return nil
}
func buildDashboardChart(ctx context.Context, conn *Conn, locale *Locale, company *Company, periodStart string, periodEnd string, selectedPeriod string) template.HTML {
group := "yyyymmdd"
dateFormat := "02/01/2006"
switch selectedPeriod {
case YearPeriod, YesteryearPeriod:
group = "yyyymm"
dateFormat = "01/2006"
}
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select date
, to_date(date::text, '%[3]s')
, 0 as sales
, to_price(0, $2) as sales_price
, coalesce(invoice.total, 0) as income
, to_price(coalesce(invoice.total, 0), $2) as income_price
, coalesce(expense.total, 0) as expenses
, to_price(coalesce(expense.total, 0), $2) as expenses_price
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(subtotal + taxes)::integer as total
from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date)
left join (
select expense_id
, invoice_date
, expense.amount as subtotal
, coalesce(sum(tax.amount)::integer, 0) as taxes
from expense
left join expense_tax_amount as tax using (expense_id)
where company_id = $1
group by expense_id
, invoice_date
, expense.amount
) as expense
on expense.invoice_date = date.invoice_date
group by date
) as expense using (date)
order by date
`, periodStart, periodEnd, group), company.Id, company.DecimalDigits)
defer rows.Close()
type value struct {
index int
date time.Time
sales float64
salesPrice string
income float64
incomePrice string
expenses float64
expensesPrice string
}
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.index, &v.date, &sales, &v.salesPrice, &income, &v.incomePrice, &expenses, &v.expensesPrice); 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 max == 0 {
max = 1
}
if rows.Err() != nil {
panic(rows.Err())
}
width := 1024.
height := 256.
dataPoints := float64(len(values)) - 1
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("<rect x='-10' y='-10' width='100%', height='100%'/>")
writePolyline := func(value func(value) float64) {
sb.WriteString("<polyline points='")
for i, v := range values {
sb.WriteString(fmt.Sprintf(" %f,%f", float64(i)/dataPoints*width, height-value(v)/max*height))
}
sb.WriteString("'/>")
}
writePolyline(func(v value) float64 { return v.sales })
writePolyline(func(v value) float64 { return v.income })
writePolyline(func(v value) float64 { return v.expenses })
writeCircle := func(i int, time time.Time, p string, v float64) {
price := formatPrice(p, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol)
date := time.Format(dateFormat)
sb.WriteString(fmt.Sprintf("<circle cx='%f' cy='%f' r='4'><title>%s\n%s</title></circle>", float64(i)/dataPoints*width, height-v/max*height, date, price))
}
for i, v := range values {
writeCircle(i, v.date, v.salesPrice, v.sales)
writeCircle(i, v.date, v.incomePrice, v.income)
writeCircle(i, v.date, v.expensesPrice, v.expenses)
}
sb.WriteString("</svg>")
return template.HTML(sb.String())
}