Compare commits

...

2 Commits

Author SHA1 Message Date
jordi fita mas 7d895fe5f9 Use HTMx to add product rows “inline” in the invoice form
I actually find more comfortable to select the product from the list
presented up until now, but this is mostly because i have very few
products and the list is not too long, so the idea is that with
JavaScript we will dynamically add an empty product row to the invoice
and then use the name field to search the product by name.

I have the feeling that i am doing something wrong because i ended up
with a lot of HTMx attribute for what i feel is not that much work,
but for now it will work.

I have added the `Is` field to `InputField` in order to include the `id`
attribute to the HTML element, because the HTMLAttributes are attached
to the `input`, not the `div`, and i felt like this one should also be
a custom element based on div, like all the others.

These is not yet any keyboard control to select the search results.

I am not happy with having the search of products in a different URL
than the index, specially since they use the exact same SQL query and
ProductFilter struct, but i did not know how else ask for a different
representation without resorting to the more complicated MIME types.
2023-04-24 02:00:38 +02:00
jordi fita mas 2ced61d304 Add the product filter form
I need a way to search products by name in the invoice form, when the
user adds or changes a product.  Since this is something that i have to
add too to the product list, i added it now so the function will already
be ready.
2023-04-23 03:20:01 +02:00
14 changed files with 354 additions and 59 deletions

View File

@ -27,6 +27,7 @@ type InputField struct {
Label string Label string
Type string Type string
Val string Val string
Is string
Required bool Required bool
Attributes []template.HTMLAttr Attributes []template.HTMLAttr
Errors []error Errors []error

View File

@ -6,10 +6,11 @@ import (
) )
const ( const (
HxLocation = "HX-Location" HxLocation = "HX-Location"
HxRefresh = "HX-Refresh" HxRefresh = "HX-Refresh"
HxRequest = "HX-Request" HxRequest = "HX-Request"
HxTrigger = "HX-Trigger" HxTrigger = "HX-Trigger"
HxTriggerAfterSettle = "HX-Trigger-After-Settle"
) )
type HTMxLocation struct { type HTMxLocation struct {

View File

@ -222,7 +222,8 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
slug := params[0].Value slug := params[0].Value
if slug == "new" { switch slug {
case "new":
locale := getLocale(r) locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company) form := newInvoiceForm(r.Context(), conn, locale, company)
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" { if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" {
@ -233,24 +234,42 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
form.Date.Val = time.Now().Format("2006-01-02") form.Date.Val = time.Now().Format("2006-01-02")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
mustRenderNewInvoiceForm(w, r, form) mustRenderNewInvoiceForm(w, r, form)
return case "product-form":
} company := mustGetCompany(r)
query := r.URL.Query()
pdf := false index, _ := strconv.Atoi(query.Get("index"))
if strings.HasSuffix(slug, ".pdf") { conn := getConn(r)
pdf = true form := newInvoiceProductForm(index, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company))
slug = slug[:len(slug)-len(".pdf")] slug := query.Get("slug")
} if len(slug) > 0 {
inv := mustGetInvoice(r.Context(), conn, company, slug) if !form.MustFillFromDatabase(r.Context(), conn, slug) {
if inv == nil { http.NotFound(w, r)
http.NotFound(w, r) return
return }
} quantity, _ := strconv.Atoi(query.Get("product.quantity." + strconv.Itoa(index)))
if pdf { if quantity > 0 {
w.Header().Set("Content-Type", "application/pdf") form.Quantity.Val = strconv.Itoa(quantity)
mustWriteInvoicePdf(w, r, inv) }
} else { w.Header().Set(HxTriggerAfterSettle, "recompute")
mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv) }
mustRenderStandaloneTemplate(w, r, "invoices/product-form.gohtml", form)
default:
pdf := false
if strings.HasSuffix(slug, ".pdf") {
pdf = true
slug = slug[:len(slug)-len(".pdf")]
}
inv := mustGetInvoice(r.Context(), conn, company, slug)
if inv == nil {
http.NotFound(w, r)
return
}
if pdf {
w.Header().Set("Content-Type", "application/pdf")
mustWriteInvoicePdf(w, r, inv)
} else {
mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv)
}
} }
} }
@ -643,7 +662,8 @@ func (form *invoiceForm) Update() {
for n, product := range products { for n, product := range products {
if product.Quantity.Val != "0" { if product.Quantity.Val != "0" {
if n != len(form.Products) { if n != len(form.Products) {
product.Reindex(len(form.Products)) product.Index = len(form.Products)
product.Rename()
} }
form.Products = append(form.Products, product) form.Products = append(form.Products, product)
} }
@ -706,6 +726,7 @@ func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*Sel
type invoiceProductForm struct { type invoiceProductForm struct {
locale *Locale locale *Locale
company *Company company *Company
Index int
InvoiceProductId *InputField InvoiceProductId *InputField
ProductId *InputField ProductId *InputField
Name *InputField Name *InputField
@ -720,6 +741,7 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
form := &invoiceProductForm{ form := &invoiceProductForm{
locale: locale, locale: locale,
company: company, company: company,
Index: index,
InvoiceProductId: &InputField{ InvoiceProductId: &InputField{
Label: pgettext("input", "Id", locale), Label: pgettext("input", "Id", locale),
Type: "hidden", Type: "hidden",
@ -734,6 +756,15 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
Label: pgettext("input", "Name", locale), Label: pgettext("input", "Name", locale),
Type: "text", Type: "text",
Required: true, Required: true,
Is: "numerus-product-search",
Attributes: []template.HTMLAttr{
`autocomplete="off"`,
`data-hx-trigger="keyup changed delay:200"`,
`data-hx-target="next .options"`,
`data-hx-indicator="closest div"`,
`data-hx-swap="innerHTML"`,
template.HTMLAttr(fmt.Sprintf(`data-hx-get="%v"`, companyURI(company, "/search/products"))),
},
}, },
Description: &InputField{ Description: &InputField{
Label: pgettext("input", "Description", locale), Label: pgettext("input", "Description", locale),
@ -771,12 +802,12 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
Options: taxOptions, Options: taxOptions,
}, },
} }
form.Reindex(index) form.Rename()
return form return form
} }
func (form *invoiceProductForm) Reindex(index int) { func (form *invoiceProductForm) Rename() {
suffix := "." + strconv.Itoa(index) suffix := "." + strconv.Itoa(form.Index)
form.InvoiceProductId.Name = "product.invoice_product_id" + suffix form.InvoiceProductId.Name = "product.invoice_product_id" + suffix
form.ProductId.Name = "product.id" + suffix form.ProductId.Name = "product.id" + suffix
form.Name.Name = "product.name" + suffix form.Name.Name = "product.name" + suffix
@ -820,6 +851,35 @@ func (form *invoiceProductForm) Validate() bool {
return validator.AllOK() return validator.AllOK()
} }
func (form *invoiceProductForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
select product_id
, name
, description
, to_price(price, decimal_digits)
, 1 as quantity
, 0 as discount
, array_remove(array_agg(tax_id), null)
from product
join company using (company_id)
join currency using (currency_code)
left join product_tax using (product_id)
where product.slug = $1
group by product_id
, name
, description
, price
, decimal_digits
`, slug).Scan(
form.ProductId,
form.Name,
form.Description,
form.Price,
form.Quantity,
form.Discount,
form.Tax))
}
func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)

View File

@ -8,6 +8,7 @@ import (
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"strings"
) )
type ProductEntry struct { type ProductEntry struct {
@ -19,14 +20,21 @@ type ProductEntry struct {
type productsIndexPage struct { type productsIndexPage struct {
Products []*ProductEntry Products []*ProductEntry
Filters *productFilterForm
} }
func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
tag := r.URL.Query().Get("tag") locale := getLocale(r)
filters := newProductFilterForm(locale)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
page := &productsIndexPage{ page := &productsIndexPage{
Products: mustCollectProductEntries(r.Context(), conn, company, tag), Products: mustCollectProductEntries(r.Context(), conn, company, filters),
Filters: filters,
} }
mustRenderMainTemplate(w, r, "products/index.gohtml", page) mustRenderMainTemplate(w, r, "products/index.gohtml", page)
} }
@ -152,8 +160,72 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
} }
} }
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, tag string) []*ProductEntry { type productFilterForm struct {
rows, err := conn.Query(ctx, ` Name *InputField
Tags *TagsField
TagsCondition *ToggleField
}
func newProductFilterForm(locale *Locale) *productFilterForm {
return &productFilterForm{
Name: &InputField{
Name: "number",
Label: pgettext("input", "Invoice Number", locale),
Type: "search",
},
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
TagsCondition: &ToggleField{
Name: "tags_condition",
Label: pgettext("input", "Tags Condition", locale),
Selected: "and",
FirstOption: &ToggleOption{
Value: "and",
Label: pgettext("tag condition", "All", locale),
Description: gettext("Invoices must have all the specified labels.", locale),
},
SecondOption: &ToggleOption{
Value: "or",
Label: pgettext("tag condition", "Any", locale),
Description: gettext("Invoices must have at least one of the specified labels.", locale),
},
},
}
}
func (form *productFilterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Name.FillValue(r)
form.Tags.FillValue(r)
form.TagsCondition.FillValue(r)
return nil
}
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, filters *productFilterForm) []*ProductEntry {
args := []interface{}{company.Id}
where := []string{"product.company_id = $1"}
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
if filters != nil {
name := strings.TrimSpace(filters.Name.String())
if name != "" {
appendWhere("product.name ilike $%d", "%"+name+"%")
}
if len(filters.Tags.Tags) > 0 {
if filters.TagsCondition.Selected == "and" {
appendWhere("product.tags @> $%d", filters.Tags)
} else {
appendWhere("product.tags && $%d", filters.Tags)
}
}
}
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select product.slug select product.slug
, product.name , product.name
, to_price(price, decimal_digits) , to_price(price, decimal_digits)
@ -161,19 +233,15 @@ func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company
from product from product
join company using (company_id) join company using (company_id)
join currency using (currency_code) join currency using (currency_code)
where product.company_id = $1 and (($2 = '') or (tags @> array[$2]::tag_name[])) where (%s)
order by name order by name
`, company.Id, tag) `, strings.Join(where, ") AND (")), args...)
if err != nil {
panic(err)
}
defer rows.Close() defer rows.Close()
var entries []*ProductEntry var entries []*ProductEntry
for rows.Next() { for rows.Next() {
entry := &ProductEntry{} entry := &ProductEntry{}
err = rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags) if err := rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags); err != nil {
if err != nil {
panic(err) panic(err)
} }
entries = append(entries, entry) entries = append(entries, entry)
@ -281,3 +349,15 @@ func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.Tax, form.Tax,
form.Tags)) form.Tags))
} }
func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
filters := newProductFilterForm(getLocale(r))
query := r.URL.Query()
index := query.Get("index")
filters.Name.Val = strings.TrimSpace(query.Get("product.name." + index))
var products []*ProductEntry
if len(filters.Name.Val) > 0 {
products = mustCollectProductEntries(r.Context(), getConn(r), mustGetCompany(r), filters)
}
mustRenderStandaloneTemplate(w, r, "products/search.gohtml", products)
}

View File

@ -33,6 +33,7 @@ func NewRouter(db *Db) http.Handler {
companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction) companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction)
companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags) companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags)
companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags) companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags)
companyRouter.GET("/search/products", HandleProductSearch)
companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderMainTemplate(w, r, "dashboard.gohtml", nil) mustRenderMainTemplate(w, r, "dashboard.gohtml", nil)
}) })

52
web/static/bars.svg Normal file
View File

@ -0,0 +1,52 @@
<svg width="16" height="16" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#494949">
<rect y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.5s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.5s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="30" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.25s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.25s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="60" width="15" height="140" rx="6">
<animate attributeName="height"
begin="0s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="90" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.25s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.25s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="120" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.5s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.5s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -614,7 +614,8 @@ main > nav {
max-width: 35rem; max-width: 35rem;
} }
[is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags, [is="numerus-multiselect"] .options { [is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags,
[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);
@ -682,6 +683,7 @@ main > nav {
min-width: initial; min-width: initial;
} }
[is="numerus-product-search"] .options,
[is="numerus-multiselect"] .options { [is="numerus-multiselect"] .options {
padding: 0; padding: 0;
border-top: 0; border-top: 0;
@ -691,6 +693,7 @@ main > nav {
z-index: 10; z-index: 10;
} }
[is="numerus-product-search"] .options li,
[is="numerus-multiselect"] .options li { [is="numerus-multiselect"] .options li {
display: block; display: block;
width: 100%; width: 100%;
@ -698,7 +701,8 @@ main > nav {
padding: 1rem 2rem; padding: 1rem 2rem;
} }
[is="numerus-multiselect"] .options li:hover, [is="numerus-multiselect"] .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 {
background-color: var(--numerus--color--light-gray); background-color: var(--numerus--color--light-gray);
} }

View File

@ -506,8 +506,52 @@ class Tags extends HTMLDivElement {
} }
} }
class ProductSearch extends HTMLDivElement {
constructor() {
super();
this.initialized = false;
this.tags = [];
}
connectedCallback() {
if (!this.isConnected) {
return;
}
if (!this.initialized) {
for (const child of this.children) {
switch (child.nodeName) {
case 'INPUT':
this.input = child;
break;
case 'LABEL':
this.label = child;
break;
}
}
if (this.label && this.input) {
this.init();
}
}
}
init() {
this.initialized = true;
const list = document.createElement('ul');
list.classList.add('options')
list.setAttribute('role', 'listbox')
this.append(list);
const indicator = document.createElement('img');
indicator.classList.add('htmx-indicator');
indicator.src = '/static/bars.svg';
this.append(indicator);
}
}
customElements.define('numerus-multiselect', Multiselect, {extends: 'div'}); customElements.define('numerus-multiselect', Multiselect, {extends: 'div'});
customElements.define('numerus-tags', Tags, {extends: 'div'}); customElements.define('numerus-tags', Tags, {extends: 'div'});
customElements.define('numerus-product-search', ProductSearch, {extends: 'div'});
let savedTitle = ''; let savedTitle = '';

View File

@ -7,7 +7,7 @@
{{ define "input-field" -}} {{ define "input-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input{{ if .Errors }} has-errors{{ end }}"{{ if .Is }} is="{{ .Is }}"{{ end }}>
{{ if eq .Type "textarea" }} {{ if eq .Type "textarea" }}
<textarea name="{{ .Name }}" id="{{ .Name }}-field" <textarea name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }} {{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
@ -112,11 +112,18 @@
{{ define "invoice-product-form" -}} {{ define "invoice-product-form" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
{{ template "hidden-field" .ProductId }} <fieldset class="new-invoice-product"
{{ template "input-field" .Name }} data-hx-select="unset"
{{ template "input-field" .Price }} data-hx-vals='{"index": {{ .Index }}}'
{{ template "input-field" .Quantity }} data-hx-include="[name='product.quantity.{{ .Index }}']"
{{ template "input-field" .Discount }} >
{{ template "input-field" .Description }} {{ template "hidden-field" .InvoiceProductId }}
{{ template "select-field" .Tax }} {{ template "hidden-field" .ProductId }}
{{ template "input-field" .Name }}
{{ template "input-field" .Price }}
{{ template "input-field" .Quantity }}
{{ template "input-field" .Discount }}
{{ template "input-field" .Description }}
{{ template "select-field" .Tax }}
</fieldset>
{{- end }} {{- end }}

View File

@ -30,14 +30,11 @@
{{ template "input-field" .Notes }} {{ template "input-field" .Notes }}
{{- range $product := .Products }} {{- range $product := .Products }}
<fieldset class="new-invoice-product"> {{ template "invoice-product-form" . }}
{{ template "hidden-field" .InvoiceProductId }}
{{ template "invoice-product-form" . }}
</fieldset>
{{- end }} {{- end }}
{{- end }} {{- end }}
<table> <table id="invoice-summary">
<tbody> <tbody>
<tr> <tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th> <th scope="row">{{(pgettext "Subtotal" "title")}}</th>
@ -59,8 +56,13 @@
<fieldset> <fieldset>
<button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit" <button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit"
name="action" value="select-products" name="action" value="select-products"
data-hx-get="{{ companyURI "/invoices/product-form" }}"
data-hx-target="#invoice-summary" data-hx-swap="beforebegin"
data-hx-select="unset"
data-hx-vals="js:{index: document.querySelectorAll('.new-invoice-product').length}"
type="submit">{{( pgettext "Add products" "action" )}}</button> type="submit">{{( pgettext "Add products" "action" )}}</button>
<button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit" <button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit"
id="recompute-button"
name="action" value="update" name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button> type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="_method" value="PUT" <button class="primary" name="_method" value="PUT"
@ -69,4 +71,10 @@
</form> </form>
</section> </section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }} {{- end }}

View File

@ -30,13 +30,11 @@
{{ template "input-field" .Notes }} {{ template "input-field" .Notes }}
{{- range $product := .Products }} {{- range $product := .Products }}
<fieldset class="new-invoice-product"> {{ template "invoice-product-form" . }}
{{ template "invoice-product-form" . }}
</fieldset>
{{- end }} {{- end }}
{{- end }} {{- end }}
<table> <table id="invoice-summary">
<tbody> <tbody>
<tr> <tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th> <th scope="row">{{(pgettext "Subtotal" "title")}}</th>
@ -58,14 +56,24 @@
<fieldset> <fieldset>
<button formnovalidate formaction="{{ companyURI "/invoices/new" }}" <button formnovalidate formaction="{{ companyURI "/invoices/new" }}"
name="action" value="select-products" name="action" value="select-products"
data-hx-get="{{ companyURI "/invoices/product-form" }}"
data-hx-target="#invoice-summary" data-hx-swap="beforebegin"
data-hx-select="unset"
data-hx-vals="js:{index: document.querySelectorAll('.new-invoice-product').length}"
type="submit">{{( pgettext "Add products" "action" )}}</button> type="submit">{{( pgettext "Add products" "action" )}}</button>
<button formnovalidate formaction="{{ companyURI "/invoices/new" }}" <button formnovalidate formaction="{{ companyURI "/invoices/new" }}"
id="recompute-button"
name="action" value="update" name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button> type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="action" value="add" <button class="primary" name="action" value="add"
type="submit">{{( pgettext "New invoice" "action" )}}</button> type="submit">{{( pgettext "New invoice" "action" )}}</button>
</fieldset> </fieldset>
</form> </form>
</section> </section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }} {{- end }}

View File

@ -0,0 +1,4 @@
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
{{ template "invoice-product-form" . }}
{{- end }}

View File

@ -19,6 +19,20 @@
{{ define "content" }} {{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.productsIndexPage*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.productsIndexPage*/ -}}
<div aria-label="{{( pgettext "Filters" "title" )}}">
<form method="GET" action="{{ companyURI "/products"}}"
data-hx-target="main"
data-hx-boost="true"
data-hx-trigger="change,search,submit"
>
{{ with .Filters }}
{{ template "input-field" .Name }}
{{ template "tags-field" .Tags | addTagsAttr (print `data-conditions="` .TagsCondition.Name `-field"`) }}
{{ template "toggle-field" .TagsCondition }}
{{ end }}
<button type="submit">{{( pgettext "Filter" "action" )}}</button>
</form>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -37,7 +51,7 @@
<td> <td>
{{- range $index, $tag := .Tags }} {{- range $index, $tag := .Tags }}
{{- if gt $index 0 }}, {{ end -}} {{- if gt $index 0 }}, {{ end -}}
<a href="?tag={{ . }}" data-hx-target="main" data-hx-boost="true">{{ . }}</a> <a href="?tags={{ . }}" data-hx-target="main" data-hx-boost="true">{{ . }}</a>
{{- end }} {{- end }}
</td> </td>
<td class="numeric">{{ .Price | formatPrice }}</td> <td class="numeric">{{ .Price | formatPrice }}</td>

View File

@ -0,0 +1,11 @@
{{ define "content" }}
{{- /*gotype: []dev.tandem.ws/tandem/numerus/pkg.ProductEntry*/ -}}
{{- range $product := . }}
<li
data-hx-get="{{ companyURI "/invoices/product-form" }}?slug={{ .Slug }}"
data-hx-target="closest fieldset"
data-hx-swap="outerHTML"
data-hx-trigger="click"
>{{ .Name }}</li>
{{- end }}
{{- end }}