Add a period filter for the dashboard

I do not yet know whether Oriol wants a YTD or MAT period, and i went
for the easiest for me: everything is MAT.
This commit is contained in:
jordi fita mas 2023-05-17 12:05:30 +02:00
parent f68aba1387
commit 987a99e0df
7 changed files with 272 additions and 35 deletions

View File

@ -5,6 +5,13 @@ import (
"net/http" "net/http"
) )
const (
YearPeriod = "year"
QuarterPeriod = "quarter"
MonthPeriod = "month"
YesteryearPeriod = "yesteryear"
)
type DashboardPage struct { type DashboardPage struct {
Sales string Sales string
Income string Income string
@ -12,10 +19,30 @@ type DashboardPage struct {
VAT string VAT string
IRPF string IRPF string
NetIncome string NetIncome string
Filters *dashboardFilterForm
} }
func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
company := mustGetCompany(r) 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 := "30 DAYS"
periodEnd := "0 DAYS"
switch filters.Period.Selected {
case YearPeriod:
periodStart = "1 YEAR"
case QuarterPeriod:
periodStart = "3 MONTHS"
case YesteryearPeriod:
periodStart = "2 YEARS"
periodEnd = "1 YEAR"
case "":
filters.Period.Selected = MonthPeriod
}
conn := getConn(r) conn := getConn(r)
rows := conn.MustQuery(r.Context(), ` rows := conn.MustQuery(r.Context(), `
select to_price(0, decimal_digits) as sales select to_price(0, decimal_digits) as sales
@ -29,11 +56,13 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
select company_id, sum(total)::integer as total select company_id, sum(total)::integer as total
from invoice from invoice
join invoice_amount using (invoice_id) join invoice_amount using (invoice_id)
where invoice_date between CURRENT_DATE - $2::interval and CURRENT_DATE - $3::interval
group by company_id group by company_id
) as invoice using (company_id) ) as invoice using (company_id)
left join ( left join (
select company_id, sum(amount)::integer as total select company_id, sum(amount)::integer as total
from expense from expense
where invoice_date between CURRENT_DATE - $2::interval and CURRENT_DATE - $3::interval
group by company_id group by company_id
) as expense using (company_id) ) as expense using (company_id)
left join ( left join (
@ -44,14 +73,17 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
join invoice_tax_amount using (invoice_id) join invoice_tax_amount using (invoice_id)
join tax using (tax_id) join tax using (tax_id)
join tax_class using (tax_class_id) join tax_class using (tax_class_id)
where invoice_date between CURRENT_DATE - $2::interval and CURRENT_DATE - $3::interval
group by invoice.company_id group by invoice.company_id
) as tax using (company_id) ) as tax using (company_id)
join currency using (currency_code) join currency using (currency_code)
where company_id = $1 where company_id = $1
`, company.Id) `, company.Id, periodStart, periodEnd)
defer rows.Close() defer rows.Close()
dashboard := &DashboardPage{} 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)
@ -62,3 +94,47 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
} }
mustRenderMainTemplate(w, r, "dashboard.gohtml", dashboard) 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", "Year", locale),
Value: YearPeriod,
},
{
Label: pgettext("period option", "Quarter", locale),
Value: QuarterPeriod,
},
{
Label: pgettext("period option", "Month", locale),
Value: MonthPeriod,
},
{
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
}

View File

@ -217,6 +217,59 @@ func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*S
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language) return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
} }
type RadioOption struct {
Value string
Label string
}
type RadioField struct {
Name string
Label string
Selected string
Options []*RadioOption
Attributes []template.HTMLAttr
Required bool
Errors []error
}
func (field *RadioField) Scan(value interface{}) error {
if value == nil {
field.Selected = ""
return nil
}
field.Selected = fmt.Sprintf("%v", value)
return nil
}
func (field *RadioField) Value() (driver.Value, error) {
return field.Selected, nil
}
func (field *RadioField) String() string {
return field.Selected
}
func (field *RadioField) FillValue(r *http.Request) {
field.Selected = strings.TrimSpace(r.FormValue(field.Name))
}
func (field *RadioField) IsSelected(v string) bool {
return field.Selected == v
}
func (field *RadioField) FindOption(value string) *RadioOption {
for _, option := range field.Options {
if option.Value == value {
return option
}
}
return nil
}
func (field *RadioField) isValidOption(selected string) bool {
return field.FindOption(selected) != nil
}
type FileField struct { type FileField struct {
Name string Name string
Label string Label string

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-05-16 14:50+0200\n" "POT-Creation-Date: 2023-05-17 11:59+0200\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -104,8 +104,9 @@ msgctxt "action"
msgid "Download invoices" msgid "Download invoices"
msgstr "Descarrega factures" msgstr "Descarrega factures"
#: web/template/invoices/index.gohtml:40 web/template/contacts/index.gohtml:32 #: web/template/invoices/index.gohtml:40 web/template/dashboard.gohtml:22
#: web/template/expenses/index.gohtml:34 web/template/products/index.gohtml:32 #: web/template/contacts/index.gohtml:32 web/template/expenses/index.gohtml:34
#: web/template/products/index.gohtml:32
msgctxt "action" msgctxt "action"
msgid "Filter" msgid "Filter"
msgstr "Filtra" msgstr "Filtra"
@ -219,7 +220,7 @@ msgid "Edit invoice"
msgstr "Edita factura" msgstr "Edita factura"
#: web/template/form.gohtml:36 #: web/template/form.gohtml:36
msgctxt "label" msgctxt "input"
msgid "(Max. %s)" msgid "(Max. %s)"
msgstr "(Màx. %s)" msgstr "(Màx. %s)"
@ -228,32 +229,32 @@ msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
msgstr "Tauler" msgstr "Tauler"
#: web/template/dashboard.gohtml:14 #: web/template/dashboard.gohtml:27
msgctxt "term" msgctxt "term"
msgid "Sales" msgid "Sales"
msgstr "Vendes" msgstr "Vendes"
#: web/template/dashboard.gohtml:18 #: web/template/dashboard.gohtml:31
msgctxt "term" msgctxt "term"
msgid "Income" msgid "Income"
msgstr "Ingressos" msgstr "Ingressos"
#: web/template/dashboard.gohtml:22 #: web/template/dashboard.gohtml:35
msgctxt "term" msgctxt "term"
msgid "Expenses" msgid "Expenses"
msgstr "Despeses" msgstr "Despeses"
#: web/template/dashboard.gohtml:26 #: web/template/dashboard.gohtml:39
msgctxt "term" msgctxt "term"
msgid "VAT" msgid "VAT"
msgstr "IVA" msgstr "IVA"
#: web/template/dashboard.gohtml:30 #: web/template/dashboard.gohtml:43
msgctxt "term" msgctxt "term"
msgid "IRPF" msgid "IRPF"
msgstr "IRPF" msgstr "IRPF"
#: web/template/dashboard.gohtml:34 #: web/template/dashboard.gohtml:47
msgctxt "term" msgctxt "term"
msgid "Net Income" msgid "Net Income"
msgstr "Ingressos nets" msgstr "Ingressos nets"
@ -738,6 +739,31 @@ msgstr "La confirmació no és igual a la contrasenya."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid." msgstr "Heu seleccionat un idioma que no és vàlid."
#: pkg/dashboard.go:112
msgctxt "input"
msgid "Period"
msgstr "Període"
#: pkg/dashboard.go:115
msgctxt "period option"
msgid "Year"
msgstr "Any"
#: pkg/dashboard.go:119
msgctxt "period option"
msgid "Quarter"
msgstr "Trimestre"
#: pkg/dashboard.go:123
msgctxt "period option"
msgid "Month"
msgstr "Mes"
#: pkg/dashboard.go:127
msgctxt "period option"
msgid "Previous Year"
msgstr "Any anterior"
#: pkg/expenses.go:129 #: pkg/expenses.go:129
msgid "Select a contact." msgid "Select a contact."
msgstr "Escolliu un contacte." msgstr "Escolliu un contacte."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-05-16 14:50+0200\n" "POT-Creation-Date: 2023-05-17 11:59+0200\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -104,8 +104,9 @@ msgctxt "action"
msgid "Download invoices" msgid "Download invoices"
msgstr "Descargar facturas" msgstr "Descargar facturas"
#: web/template/invoices/index.gohtml:40 web/template/contacts/index.gohtml:32 #: web/template/invoices/index.gohtml:40 web/template/dashboard.gohtml:22
#: web/template/expenses/index.gohtml:34 web/template/products/index.gohtml:32 #: web/template/contacts/index.gohtml:32 web/template/expenses/index.gohtml:34
#: web/template/products/index.gohtml:32
msgctxt "action" msgctxt "action"
msgid "Filter" msgid "Filter"
msgstr "Filtrar" msgstr "Filtrar"
@ -219,7 +220,7 @@ msgid "Edit invoice"
msgstr "Editar factura" msgstr "Editar factura"
#: web/template/form.gohtml:36 #: web/template/form.gohtml:36
msgctxt "label" msgctxt "input"
msgid "(Max. %s)" msgid "(Max. %s)"
msgstr "(Máx. %s)" msgstr "(Máx. %s)"
@ -228,32 +229,32 @@ msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
msgstr "Panel" msgstr "Panel"
#: web/template/dashboard.gohtml:14 #: web/template/dashboard.gohtml:27
msgctxt "term" msgctxt "term"
msgid "Sales" msgid "Sales"
msgstr "Ventas" msgstr "Ventas"
#: web/template/dashboard.gohtml:18 #: web/template/dashboard.gohtml:31
msgctxt "term" msgctxt "term"
msgid "Income" msgid "Income"
msgstr "Ingresos" msgstr "Ingresos"
#: web/template/dashboard.gohtml:22 #: web/template/dashboard.gohtml:35
msgctxt "term" msgctxt "term"
msgid "Expenses" msgid "Expenses"
msgstr "Gastos" msgstr "Gastos"
#: web/template/dashboard.gohtml:26 #: web/template/dashboard.gohtml:39
msgctxt "term" msgctxt "term"
msgid "VAT" msgid "VAT"
msgstr "IVA" msgstr "IVA"
#: web/template/dashboard.gohtml:30 #: web/template/dashboard.gohtml:43
msgctxt "term" msgctxt "term"
msgid "IRPF" msgid "IRPF"
msgstr "IRPF" msgstr "IRPF"
#: web/template/dashboard.gohtml:34 #: web/template/dashboard.gohtml:47
msgctxt "term" msgctxt "term"
msgid "Net Income" msgid "Net Income"
msgstr "Ingresos netos" msgstr "Ingresos netos"
@ -738,6 +739,31 @@ msgstr "La confirmación no corresponde con la contraseña."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido." msgstr "Habéis escogido un idioma que no es válido."
#: pkg/dashboard.go:112
msgctxt "input"
msgid "Period"
msgstr "Periodo"
#: pkg/dashboard.go:115
msgctxt "period option"
msgid "Year"
msgstr "Año"
#: pkg/dashboard.go:119
msgctxt "period option"
msgid "Quarter"
msgstr "Trimestre"
#: pkg/dashboard.go:123
msgctxt "period option"
msgid "Month"
msgstr "Mes"
#: pkg/dashboard.go:127
msgctxt "period option"
msgid "Previous Year"
msgstr "Año anterior"
#: pkg/expenses.go:129 #: pkg/expenses.go:129
msgid "Select a contact." msgid "Select a contact."
msgstr "Escoged un contacto" msgstr "Escoged un contacto"

View File

@ -309,9 +309,12 @@ main {
} }
.input { .input {
margin-top: 2rem;
}
.input:not(.radio) {
position: relative; position: relative;
display: inline-block; display: inline-block;
margin-top: 2rem;
} }
input[type="text"], input[type="search"], input[type="password"], input[type="email"], input[type="tel"], input[type="url"], input[type="number"], input[type="date"], select, textarea { input[type="text"], input[type="search"], input[type="password"], input[type="email"], input[type="tel"], input[type="url"], input[type="number"], input[type="date"], select, textarea {
@ -334,7 +337,7 @@ input.width-2x {
color: transparent; color: transparent;
} }
.input label, .input input:focus ~ label { .input:not(.radio) label, .input:not(.radio) input:focus ~ label {
position: absolute; position: absolute;
font-style: italic; font-style: italic;
pointer-events: none; pointer-events: none;
@ -365,7 +368,7 @@ input.width-2x {
content: " (opcional)" content: " (opcional)"
} }
.input label, .input input:focus ~ label { .input:not(.radio) label, .input:not(.radio) input:focus ~ label {
background-color: var(--numerus--background-color); background-color: var(--numerus--background-color);
top: -.9rem; top: -.9rem;
left: 2rem; left: 2rem;
@ -384,17 +387,23 @@ input.width-2x {
fieldset { fieldset {
border: none; border: none;
padding: 2rem 0 0;
margin-top: 3rem;
border-top: 1px solid var(--numerus--color--light-gray);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
} }
fieldset:not(.radio) {
padding: 2rem 0 0;
margin-top: 3rem;
border-top: 1px solid var(--numerus--color--light-gray);
}
legend { legend {
float: left;
font-style: italic; font-style: italic;
}
fieldset:not(.radio) {
float: left;
margin-bottom: 3rem; margin-bottom: 3rem;
width: 100%; width: 100%;
} }
@ -615,7 +624,7 @@ main > nav {
} }
[is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags, [is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags,
[is="numerus-multiselect"] .options, [is="numerus-product-search"] .options { [is="numerus-multiselect"] .options, [is="numerus-product-search"] .options {
font-size: 1em; font-size: 1em;
list-style: none; list-style: none;
color: var(--numerus--text-color); color: var(--numerus--text-color);
@ -701,8 +710,8 @@ main > nav {
padding: 1rem 2rem; padding: 1rem 2rem;
} }
[is="numerus-product-search"] .options li:hover,[is="numerus-product-search"] .options .highlight, [is="numerus-product-search"] .options li:hover, [is="numerus-product-search"] .options .highlight,
[is="numerus-multiselect"] .options li:hover,[is="numerus-multiselect"] .options .highlight { [is="numerus-multiselect"] .options li:hover, [is="numerus-multiselect"] .options .highlight {
background-color: var(--numerus--color--light-gray); background-color: var(--numerus--color--light-gray);
} }
@ -825,6 +834,23 @@ div[x-data="snackbar"] div[role="alert"].enter.end, div[x-data="snackbar"] div[r
} }
/* Dashboard */ /* Dashboard */
#dashboard-filters {
display: flex;
}
#dashboard-filters .radio {
margin-top: 0;
flex: 1;
}
#dashboard-filters .radio label {
text-transform: lowercase;
}
#dashboard-filters legend {
display: none;
}
#income-statement { #income-statement {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -834,7 +860,7 @@ div[x-data="snackbar"] div[role="alert"].enter.end, div[x-data="snackbar"] div[r
#income-statement div { #income-statement div {
display: block; display: block;
padding: 2rem; padding: 2rem;
width: calc(100%/3); width: calc(100% / 3);
min-width: 33rem; min-width: 33rem;
} }

View File

@ -9,6 +9,19 @@
{{ define "content" }} {{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.DashboardPage*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.DashboardPage*/ -}}
<div aria-label="{{( pgettext "Filters" "title" )}}">
<form method="GET" action="{{ companyURI "/"}}"
id="dashboard-filters"
data-hx-target="main"
data-hx-boost="true"
data-hx-trigger="change,submit"
>
{{ with .Filters }}
{{ template "radio-field" .Period }}
{{ end }}
<button type="submit">{{( pgettext "Filter" "action" )}}</button>
</form>
</div>
<dl id="income-statement"> <dl id="income-statement">
<div> <div>
<dt>{{ (pgettext "Sales" "term") }}</dt> <dt>{{ (pgettext "Sales" "term") }}</dt>

View File

@ -33,7 +33,7 @@
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.FileField*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.FileField*/ -}}
<div class="input{{ if .Errors }} has-errors{{ end }}"> <div class="input{{ if .Errors }} has-errors{{ end }}">
<input type="file" name="{{ .Name }}" id="{{ .Name }}-field" placeholder="{{ .Label }}"> <input type="file" name="{{ .Name }}" id="{{ .Name }}-field" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}{{ if gt .MaxSize 0 }} {{printf (pgettext "(Max. %s)" "label") (.MaxSize|humanizeBytes) }}{{ end }}</label> <label for="{{ .Name }}-field">{{ .Label }}{{ if gt .MaxSize 0 }} {{printf (pgettext "(Max. %s)" "input") (.MaxSize|humanizeBytes) }}{{ end }}</label>
{{- if .Errors }} {{- if .Errors }}
<ul> <ul>
{{- range $error := .Errors }} {{- range $error := .Errors }}
@ -125,6 +125,23 @@
</fieldset> </fieldset>
{{- end }} {{- end }}
{{ define "radio-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.RadioField*/ -}}
<fieldset id="{{ .Name}}-field" class="input radio{{ if .Errors }} has-errors{{ end }}">
<legend>{{ .Label }}</legend>
{{- range $option := .Options }}
<label><input type="radio" name="{{$.Name}}" value="{{.Value}}" {{- if $.IsSelected .Value }} checked="checked"{{ end }}> {{.Label}}</label>
{{- end }}
{{- if .Errors }}
<ul>
{{- range $error := .Errors }}
<li>{{ . }}</li>
{{- end }}
</ul>
{{- end }}
</fieldset>
{{- end }}
{{ define "invoice-product-form" -}} {{ define "invoice-product-form" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
<fieldset class="new-invoice-product" <fieldset class="new-invoice-product"