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), 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(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, 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("", int(width)+20, int(height)+20)) sb.WriteString("") writePolyline := func(value func(value) float64) { 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("%s\n%s", 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("") return template.HTML(sb.String()) }