2023-05-16 12:56:49 +00:00
|
|
|
package pkg
|
|
|
|
|
|
|
|
import (
|
2023-05-20 13:53:59 +00:00
|
|
|
"context"
|
2023-05-19 12:05:57 +00:00
|
|
|
"fmt"
|
2023-05-16 12:56:49 +00:00
|
|
|
"github.com/julienschmidt/httprouter"
|
2023-05-20 13:53:59 +00:00
|
|
|
"html/template"
|
|
|
|
"math"
|
2023-05-16 12:56:49 +00:00
|
|
|
"net/http"
|
2023-05-20 13:53:59 +00:00
|
|
|
"strings"
|
2023-05-21 17:22:46 +00:00
|
|
|
"time"
|
2023-05-16 12:56:49 +00:00
|
|
|
)
|
|
|
|
|
2023-05-17 10:05:30 +00:00
|
|
|
const (
|
2023-05-19 12:05:57 +00:00
|
|
|
MonthPeriod = "month"
|
|
|
|
YestermonthPeriod = "yestermonth"
|
|
|
|
QuarterPeriod = "quarter"
|
|
|
|
YesterquarterPeriod = "yesterquarter"
|
|
|
|
YearPeriod = "year"
|
|
|
|
YesteryearPeriod = "yesteryear"
|
2023-05-17 10:05:30 +00:00
|
|
|
)
|
|
|
|
|
2023-05-16 12:56:49 +00:00
|
|
|
type DashboardPage struct {
|
|
|
|
Sales string
|
|
|
|
Income string
|
|
|
|
Expenses string
|
|
|
|
VAT string
|
|
|
|
IRPF string
|
|
|
|
NetIncome string
|
2023-05-20 13:53:59 +00:00
|
|
|
Chart template.HTML
|
2023-05-17 10:05:30 +00:00
|
|
|
Filters *dashboardFilterForm
|
2023-05-16 12:56:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
|
|
company := mustGetCompany(r)
|
2023-05-17 10:05:30 +00:00
|
|
|
locale := getLocale(r)
|
|
|
|
filters := newDashboardFilterForm(locale, company)
|
|
|
|
if err := filters.Parse(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
2023-05-19 12:05:57 +00:00
|
|
|
periodStart := "date_trunc('month', current_date)::date"
|
|
|
|
periodEnd := "current_date"
|
2023-05-17 10:05:30 +00:00
|
|
|
switch filters.Period.Selected {
|
2023-05-19 12:05:57 +00:00
|
|
|
case YestermonthPeriod:
|
|
|
|
periodStart = "date_trunc('month', current_date - interval '1 month')::date"
|
|
|
|
periodEnd = "(date_trunc('month', current_date) - interval '1 day')::date"
|
2023-05-17 10:05:30 +00:00
|
|
|
case QuarterPeriod:
|
2023-05-19 12:05:57 +00:00
|
|
|
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"
|
2023-05-17 10:05:30 +00:00
|
|
|
case YesteryearPeriod:
|
2023-05-19 12:05:57 +00:00
|
|
|
periodStart = "date_trunc('year', current_date - interval '1 year')::date"
|
|
|
|
periodEnd = "(date_trunc('year', current_date) - interval '1 day')::date"
|
2023-05-17 10:05:30 +00:00
|
|
|
case "":
|
|
|
|
filters.Period.Selected = MonthPeriod
|
|
|
|
}
|
2023-05-16 12:56:49 +00:00
|
|
|
conn := getConn(r)
|
2023-05-20 13:53:59 +00:00
|
|
|
dashboard := &DashboardPage{
|
|
|
|
Filters: filters,
|
2023-05-21 17:22:46 +00:00
|
|
|
Chart: buildDashboardChart(r.Context(), conn, locale, company, periodStart, periodEnd, filters.Period.Selected),
|
2023-05-20 13:53:59 +00:00
|
|
|
}
|
2023-05-19 12:05:57 +00:00
|
|
|
rows := conn.MustQuery(r.Context(), fmt.Sprintf(`
|
2023-05-16 12:56:49 +00:00
|
|
|
select to_price(0, decimal_digits) as sales
|
2023-05-16 13:14:20 +00:00
|
|
|
, to_price(coalesce(invoice.total, 0), decimal_digits) as income
|
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
|
|
|
, to_price(coalesce(expense.total, 0) + coalesce(expense_tax.vat, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as expenses
|
2023-05-18 10:35:56 +00:00
|
|
|
, 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
|
2023-05-16 12:56:49 +00:00
|
|
|
from company
|
|
|
|
left join (
|
|
|
|
select company_id, sum(total)::integer as total
|
|
|
|
from invoice
|
|
|
|
join invoice_amount using (invoice_id)
|
2023-05-19 12:05:57 +00:00
|
|
|
where invoice_date between %[1]s and %[2]s
|
2023-05-16 12:56:49 +00:00
|
|
|
group by company_id
|
|
|
|
) as invoice using (company_id)
|
|
|
|
left join (
|
|
|
|
select company_id, sum(amount)::integer as total
|
|
|
|
from expense
|
2023-05-19 12:05:57 +00:00
|
|
|
where invoice_date between %[1]s and %[2]s
|
2023-05-16 12:56:49 +00:00
|
|
|
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)
|
2023-05-19 12:05:57 +00:00
|
|
|
where invoice_date between %[1]s and %[2]s
|
2023-05-16 12:56:49 +00:00
|
|
|
group by invoice.company_id
|
2023-05-18 10:35:56 +00:00
|
|
|
) 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)
|
2023-05-19 12:05:57 +00:00
|
|
|
where invoice_date between %[1]s and %[2]s
|
2023-05-18 10:35:56 +00:00
|
|
|
group by expense.company_id
|
|
|
|
) as expense_tax using (company_id)
|
2023-05-16 12:56:49 +00:00
|
|
|
join currency using (currency_code)
|
|
|
|
where company_id = $1
|
2023-05-19 12:05:57 +00:00
|
|
|
`, periodStart, periodEnd), company.Id)
|
2023-05-16 12:56:49 +00:00
|
|
|
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)
|
|
|
|
}
|
2023-05-17 10:05:30 +00:00
|
|
|
|
|
|
|
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{
|
|
|
|
{
|
2023-05-19 12:05:57 +00:00
|
|
|
Label: pgettext("period option", "Month", locale),
|
|
|
|
Value: MonthPeriod,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Label: pgettext("period option", "Previous month", locale),
|
|
|
|
Value: YestermonthPeriod,
|
2023-05-17 10:05:30 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
Label: pgettext("period option", "Quarter", locale),
|
|
|
|
Value: QuarterPeriod,
|
|
|
|
},
|
|
|
|
{
|
2023-05-19 12:05:57 +00:00
|
|
|
Label: pgettext("period option", "Previous quarter", locale),
|
|
|
|
Value: YesterquarterPeriod,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Label: pgettext("period option", "Year", locale),
|
|
|
|
Value: YearPeriod,
|
2023-05-17 10:05:30 +00:00
|
|
|
},
|
|
|
|
{
|
2023-05-19 12:05:57 +00:00
|
|
|
Label: pgettext("period option", "Previous year", locale),
|
2023-05-17 10:05:30 +00:00
|
|
|
Value: YesteryearPeriod,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (form *dashboardFilterForm) Parse(r *http.Request) error {
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
form.Period.FillValue(r)
|
|
|
|
return nil
|
|
|
|
}
|
2023-05-20 13:53:59 +00:00
|
|
|
|
2023-05-21 17:22:46 +00:00
|
|
|
func buildDashboardChart(ctx context.Context, conn *Conn, locale *Locale, company *Company, periodStart string, periodEnd string, selectedPeriod string) template.HTML {
|
2023-05-20 13:53:59 +00:00
|
|
|
group := "yyyymmdd"
|
2023-05-21 17:22:46 +00:00
|
|
|
dateFormat := "02/01/2006"
|
2023-05-20 13:53:59 +00:00
|
|
|
switch selectedPeriod {
|
|
|
|
case YearPeriod, YesteryearPeriod:
|
|
|
|
group = "yyyymm"
|
2023-05-21 17:22:46 +00:00
|
|
|
dateFormat = "01/2006"
|
2023-05-20 13:53:59 +00:00
|
|
|
}
|
|
|
|
rows := conn.MustQuery(ctx, fmt.Sprintf(`
|
|
|
|
select date
|
2023-05-21 17:22:46 +00:00
|
|
|
, to_date(date::text, '%[3]s')
|
|
|
|
, 0 as sales
|
|
|
|
, to_price(0, $2) as sales_price
|
2023-05-20 13:53:59 +00:00
|
|
|
, coalesce(invoice.total, 0) as income
|
2023-05-21 17:22:46 +00:00
|
|
|
, to_price(coalesce(invoice.total, 0), $2) as income_price
|
2023-05-20 13:53:59 +00:00
|
|
|
, coalesce(expense.total, 0) as expenses
|
2023-05-21 17:22:46 +00:00
|
|
|
, to_price(coalesce(expense.total, 0), $2) as expenses_price
|
2023-05-20 13:53:59 +00:00
|
|
|
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
|
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
|
|
|
, sum(subtotal + taxes)::integer as total
|
2023-05-20 13:53:59 +00:00
|
|
|
from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date)
|
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
|
|
|
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
|
2023-05-20 13:53:59 +00:00
|
|
|
group by date
|
|
|
|
) as expense using (date)
|
|
|
|
order by date
|
2023-05-21 17:22:46 +00:00
|
|
|
`, periodStart, periodEnd, group), company.Id, company.DecimalDigits)
|
2023-05-20 13:53:59 +00:00
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
type value struct {
|
2023-05-21 17:22:46 +00:00
|
|
|
index int
|
|
|
|
date time.Time
|
|
|
|
sales float64
|
|
|
|
salesPrice string
|
|
|
|
income float64
|
|
|
|
incomePrice string
|
|
|
|
expenses float64
|
|
|
|
expensesPrice string
|
2023-05-20 13:53:59 +00:00
|
|
|
}
|
|
|
|
var values []value
|
|
|
|
var max = 0.
|
|
|
|
for rows.Next() {
|
|
|
|
var v value
|
|
|
|
var sales int
|
|
|
|
var income int
|
|
|
|
var expenses int
|
2023-05-21 17:22:46 +00:00
|
|
|
if err := rows.Scan(&v.index, &v.date, &sales, &v.salesPrice, &income, &v.incomePrice, &expenses, &v.expensesPrice); err != nil {
|
2023-05-20 13:53:59 +00:00
|
|
|
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)
|
|
|
|
}
|
2023-05-20 22:14:48 +00:00
|
|
|
if max == 0 {
|
|
|
|
max = 1
|
|
|
|
}
|
2023-05-20 13:53:59 +00:00
|
|
|
if rows.Err() != nil {
|
|
|
|
panic(rows.Err())
|
|
|
|
}
|
|
|
|
|
|
|
|
width := 1024.
|
2023-05-20 22:14:48 +00:00
|
|
|
height := 256.
|
|
|
|
dataPoints := float64(len(values)) - 1
|
2023-05-20 13:53:59 +00:00
|
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(fmt.Sprintf("<svg id='income-chart' viewBox='-10 -10 %d %d'>", int(width)+20, int(height)+20))
|
2023-05-20 22:14:48 +00:00
|
|
|
sb.WriteString("<rect x='-10' y='-10' width='100%', height='100%'/>")
|
2023-05-21 16:59:42 +00:00
|
|
|
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("'/>")
|
2023-05-20 13:53:59 +00:00
|
|
|
}
|
2023-05-21 16:59:42 +00:00
|
|
|
writePolyline(func(v value) float64 { return v.sales })
|
|
|
|
writePolyline(func(v value) float64 { return v.income })
|
|
|
|
writePolyline(func(v value) float64 { return v.expenses })
|
|
|
|
|
2023-05-21 17:22:46 +00:00
|
|
|
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))
|
2023-05-20 13:53:59 +00:00
|
|
|
}
|
|
|
|
for i, v := range values {
|
2023-05-21 17:22:46 +00:00
|
|
|
writeCircle(i, v.date, v.salesPrice, v.sales)
|
|
|
|
writeCircle(i, v.date, v.incomePrice, v.income)
|
|
|
|
writeCircle(i, v.date, v.expensesPrice, v.expenses)
|
2023-05-20 13:53:59 +00:00
|
|
|
}
|
|
|
|
sb.WriteString("</svg>")
|
|
|
|
return template.HTML(sb.String())
|
|
|
|
}
|