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.
This commit is contained in:
parent
2ced61d304
commit
7d895fe5f9
|
@ -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
|
||||||
|
|
|
@ -10,6 +10,7 @@ const (
|
||||||
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 {
|
||||||
|
|
|
@ -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,9 +234,26 @@ 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)
|
||||||
|
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
|
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
|
pdf := false
|
||||||
if strings.HasSuffix(slug, ".pdf") {
|
if strings.HasSuffix(slug, ".pdf") {
|
||||||
pdf = true
|
pdf = true
|
||||||
|
@ -253,6 +271,7 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
|
||||||
mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv)
|
mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustWriteInvoicePdf(w io.Writer, r *http.Request, inv *invoice) {
|
func mustWriteInvoicePdf(w io.Writer, r *http.Request, inv *invoice) {
|
||||||
cmd := exec.Command("weasyprint", "--format", "pdf", "--stylesheet", "web/static/invoice.css", "-", "-")
|
cmd := exec.Command("weasyprint", "--format", "pdf", "--stylesheet", "web/static/invoice.css", "-", "-")
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -351,13 +351,13 @@ func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
var name string
|
filters := newProductFilterForm(getLocale(r))
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
for k := range query {
|
index := query.Get("index")
|
||||||
vs := query[k]
|
filters.Name.Val = strings.TrimSpace(query.Get("product.name." + index))
|
||||||
if len(vs) > 0 {
|
var products []*ProductEntry
|
||||||
name = vs[0]
|
if len(filters.Name.Val) > 0 {
|
||||||
|
products = mustCollectProductEntries(r.Context(), getConn(r), mustGetCompany(r), filters)
|
||||||
}
|
}
|
||||||
}
|
mustRenderStandaloneTemplate(w, r, "products/search.gohtml", products)
|
||||||
w.Write([]byte(name))
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 |
|
@ -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,6 +701,7 @@ main > nav {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = '';
|
||||||
|
|
||||||
|
|
|
@ -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,6 +112,12 @@
|
||||||
|
|
||||||
{{ 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"
|
||||||
|
data-hx-select="unset"
|
||||||
|
data-hx-vals='{"index": {{ .Index }}}'
|
||||||
|
data-hx-include="[name='product.quantity.{{ .Index }}']"
|
||||||
|
>
|
||||||
|
{{ template "hidden-field" .InvoiceProductId }}
|
||||||
{{ template "hidden-field" .ProductId }}
|
{{ template "hidden-field" .ProductId }}
|
||||||
{{ template "input-field" .Name }}
|
{{ template "input-field" .Name }}
|
||||||
{{ template "input-field" .Price }}
|
{{ template "input-field" .Price }}
|
||||||
|
@ -119,4 +125,5 @@
|
||||||
{{ template "input-field" .Discount }}
|
{{ template "input-field" .Discount }}
|
||||||
{{ template "input-field" .Description }}
|
{{ template "input-field" .Description }}
|
||||||
{{ template "select-field" .Tax }}
|
{{ template "select-field" .Tax }}
|
||||||
|
</fieldset>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
|
@ -30,14 +30,11 @@
|
||||||
{{ template "input-field" .Notes }}
|
{{ template "input-field" .Notes }}
|
||||||
|
|
||||||
{{- range $product := .Products }}
|
{{- range $product := .Products }}
|
||||||
<fieldset class="new-invoice-product">
|
|
||||||
{{ template "hidden-field" .InvoiceProductId }}
|
|
||||||
{{ 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>
|
||||||
|
@ -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 }}
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
|
||||||
|
{{ template "invoice-product-form" . }}
|
||||||
|
{{- end }}
|
|
@ -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 }}
|
Loading…
Reference in New Issue