diff --git a/pkg/form.go b/pkg/form.go index 81bde52..c205399 100644 --- a/pkg/form.go +++ b/pkg/form.go @@ -27,6 +27,7 @@ type InputField struct { Label string Type string Val string + Is string Required bool Attributes []template.HTMLAttr Errors []error diff --git a/pkg/htmx.go b/pkg/htmx.go index d11b831..6eb963d 100644 --- a/pkg/htmx.go +++ b/pkg/htmx.go @@ -6,10 +6,11 @@ import ( ) const ( - HxLocation = "HX-Location" - HxRefresh = "HX-Refresh" - HxRequest = "HX-Request" - HxTrigger = "HX-Trigger" + HxLocation = "HX-Location" + HxRefresh = "HX-Refresh" + HxRequest = "HX-Request" + HxTrigger = "HX-Trigger" + HxTriggerAfterSettle = "HX-Trigger-After-Settle" ) type HTMxLocation struct { diff --git a/pkg/invoices.go b/pkg/invoices.go index e5acce4..79263a7 100644 --- a/pkg/invoices.go +++ b/pkg/invoices.go @@ -222,7 +222,8 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para conn := getConn(r) company := mustGetCompany(r) slug := params[0].Value - if slug == "new" { + switch slug { + case "new": locale := getLocale(r) form := newInvoiceForm(r.Context(), conn, locale, company) 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") w.WriteHeader(http.StatusOK) mustRenderNewInvoiceForm(w, r, form) - return - } - - 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) + case "product-form": + company := mustGetCompany(r) + query := r.URL.Query() + index, _ := strconv.Atoi(query.Get("index")) + conn := getConn(r) + form := newInvoiceProductForm(index, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company)) + slug := query.Get("slug") + if len(slug) > 0 { + if !form.MustFillFromDatabase(r.Context(), conn, slug) { + http.NotFound(w, r) + return + } + quantity, _ := strconv.Atoi(query.Get("product.quantity." + strconv.Itoa(index))) + if quantity > 0 { + form.Quantity.Val = strconv.Itoa(quantity) + } + w.Header().Set(HxTriggerAfterSettle, "recompute") + } + 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 { if product.Quantity.Val != "0" { if n != len(form.Products) { - product.Reindex(len(form.Products)) + product.Index = len(form.Products) + product.Rename() } form.Products = append(form.Products, product) } @@ -706,6 +726,7 @@ func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*Sel type invoiceProductForm struct { locale *Locale company *Company + Index int InvoiceProductId *InputField ProductId *InputField Name *InputField @@ -720,6 +741,7 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio form := &invoiceProductForm{ locale: locale, company: company, + Index: index, InvoiceProductId: &InputField{ Label: pgettext("input", "Id", locale), Type: "hidden", @@ -734,6 +756,15 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio Label: pgettext("input", "Name", locale), Type: "text", 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{ Label: pgettext("input", "Description", locale), @@ -771,12 +802,12 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio Options: taxOptions, }, } - form.Reindex(index) + form.Rename() return form } -func (form *invoiceProductForm) Reindex(index int) { - suffix := "." + strconv.Itoa(index) +func (form *invoiceProductForm) Rename() { + suffix := "." + strconv.Itoa(form.Index) form.InvoiceProductId.Name = "product.invoice_product_id" + suffix form.ProductId.Name = "product.id" + suffix form.Name.Name = "product.name" + suffix @@ -820,6 +851,35 @@ func (form *invoiceProductForm) Validate() bool { 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) { locale := getLocale(r) conn := getConn(r) diff --git a/pkg/products.go b/pkg/products.go index ce34053..f8d87c1 100644 --- a/pkg/products.go +++ b/pkg/products.go @@ -351,13 +351,13 @@ func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s } func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - var name string + filters := newProductFilterForm(getLocale(r)) query := r.URL.Query() - for k := range query { - vs := query[k] - if len(vs) > 0 { - name = vs[0] - } + 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) } - w.Write([]byte(name)) + mustRenderStandaloneTemplate(w, r, "products/search.gohtml", products) } diff --git a/pkg/router.go b/pkg/router.go index c007c4d..3b3b45c 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -33,6 +33,7 @@ func NewRouter(db *Db) http.Handler { companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction) companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags) companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags) + companyRouter.GET("/search/products", HandleProductSearch) companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { mustRenderMainTemplate(w, r, "dashboard.gohtml", nil) }) diff --git a/web/static/bars.svg b/web/static/bars.svg new file mode 100644 index 0000000..f2c59fd --- /dev/null +++ b/web/static/bars.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/static/numerus.css b/web/static/numerus.css index 308856e..6783f7b 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -614,7 +614,8 @@ main > nav { 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; list-style: none; color: var(--numerus--text-color); @@ -682,6 +683,7 @@ main > nav { min-width: initial; } +[is="numerus-product-search"] .options, [is="numerus-multiselect"] .options { padding: 0; border-top: 0; @@ -691,6 +693,7 @@ main > nav { z-index: 10; } +[is="numerus-product-search"] .options li, [is="numerus-multiselect"] .options li { display: block; width: 100%; @@ -698,7 +701,8 @@ main > nav { 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); } diff --git a/web/static/numerus.js b/web/static/numerus.js index e3cb1d9..761d13f 100644 --- a/web/static/numerus.js +++ b/web/static/numerus.js @@ -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-tags', Tags, {extends: 'div'}); +customElements.define('numerus-product-search', ProductSearch, {extends: 'div'}); let savedTitle = ''; diff --git a/web/template/form.gohtml b/web/template/form.gohtml index a9c6de9..3f4445c 100644 --- a/web/template/form.gohtml +++ b/web/template/form.gohtml @@ -7,7 +7,7 @@ {{ define "input-field" -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}} -
+
{{ if eq .Type "textarea" }}