Compare commits

..

No commits in common. "7d895fe5f90d13caa970cdc8bed916fec3d24d0e" and "c2f6d299b47a0bcefb9af5bdcb293ba420a80986" have entirely different histories.

14 changed files with 59 additions and 354 deletions

View File

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

View File

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

View File

@ -222,8 +222,7 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
conn := getConn(r)
company := mustGetCompany(r)
slug := params[0].Value
switch slug {
case "new":
if slug == "new" {
locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company)
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" {
@ -234,26 +233,9 @@ 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)
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
@ -270,7 +252,6 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
} else {
mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv)
}
}
}
func mustWriteInvoicePdf(w io.Writer, r *http.Request, inv *invoice) {
@ -662,8 +643,7 @@ func (form *invoiceForm) Update() {
for n, product := range products {
if product.Quantity.Val != "0" {
if n != len(form.Products) {
product.Index = len(form.Products)
product.Rename()
product.Reindex(len(form.Products))
}
form.Products = append(form.Products, product)
}
@ -726,7 +706,6 @@ 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
@ -741,7 +720,6 @@ 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",
@ -756,15 +734,6 @@ 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),
@ -802,12 +771,12 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
Options: taxOptions,
},
}
form.Rename()
form.Reindex(index)
return form
}
func (form *invoiceProductForm) Rename() {
suffix := "." + strconv.Itoa(form.Index)
func (form *invoiceProductForm) Reindex(index int) {
suffix := "." + strconv.Itoa(index)
form.InvoiceProductId.Name = "product.invoice_product_id" + suffix
form.ProductId.Name = "product.id" + suffix
form.Name.Name = "product.name" + suffix
@ -851,35 +820,6 @@ 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)

View File

@ -8,7 +8,6 @@ import (
"math"
"net/http"
"strconv"
"strings"
)
type ProductEntry struct {
@ -20,21 +19,14 @@ type ProductEntry struct {
type productsIndexPage struct {
Products []*ProductEntry
Filters *productFilterForm
}
func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
filters := newProductFilterForm(locale)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tag := r.URL.Query().Get("tag")
page := &productsIndexPage{
Products: mustCollectProductEntries(r.Context(), conn, company, filters),
Filters: filters,
Products: mustCollectProductEntries(r.Context(), conn, company, tag),
}
mustRenderMainTemplate(w, r, "products/index.gohtml", page)
}
@ -160,72 +152,8 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
}
}
type productFilterForm struct {
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(`
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, tag string) []*ProductEntry {
rows, err := conn.Query(ctx, `
select product.slug
, product.name
, to_price(price, decimal_digits)
@ -233,15 +161,19 @@ func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company
from product
join company using (company_id)
join currency using (currency_code)
where (%s)
where product.company_id = $1 and (($2 = '') or (tags @> array[$2]::tag_name[]))
order by name
`, strings.Join(where, ") AND (")), args...)
`, company.Id, tag)
if err != nil {
panic(err)
}
defer rows.Close()
var entries []*ProductEntry
for rows.Next() {
entry := &ProductEntry{}
if err := rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags); err != nil {
err = rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags)
if err != nil {
panic(err)
}
entries = append(entries, entry)
@ -349,15 +281,3 @@ func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.Tax,
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,7 +33,6 @@ 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)
})

View File

@ -1,52 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

@ -506,52 +506,8 @@ 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 = '';

View File

@ -7,7 +7,7 @@
{{ define "input-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}}
<div class="input{{ if .Errors }} has-errors{{ end }}"{{ if .Is }} is="{{ .Is }}"{{ end }}>
<div class="input {{ if .Errors }}has-errors{{ end }}">
{{ if eq .Type "textarea" }}
<textarea name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
@ -112,12 +112,6 @@
{{ define "invoice-product-form" -}}
{{- /*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 "input-field" .Name }}
{{ template "input-field" .Price }}
@ -125,5 +119,4 @@
{{ template "input-field" .Discount }}
{{ template "input-field" .Description }}
{{ template "select-field" .Tax }}
</fieldset>
{{- end }}

View File

@ -30,11 +30,14 @@
{{ template "input-field" .Notes }}
{{- range $product := .Products }}
<fieldset class="new-invoice-product">
{{ template "hidden-field" .InvoiceProductId }}
{{ template "invoice-product-form" . }}
</fieldset>
{{- end }}
{{- end }}
<table id="invoice-summary">
<table>
<tbody>
<tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th>
@ -56,13 +59,8 @@
<fieldset>
<button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit"
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>
<button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit"
id="recompute-button"
name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="_method" value="PUT"
@ -71,10 +69,4 @@
</form>
</section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }}

View File

@ -30,11 +30,13 @@
{{ template "input-field" .Notes }}
{{- range $product := .Products }}
<fieldset class="new-invoice-product">
{{ template "invoice-product-form" . }}
</fieldset>
{{- end }}
{{- end }}
<table id="invoice-summary">
<table>
<tbody>
<tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th>
@ -56,24 +58,14 @@
<fieldset>
<button formnovalidate formaction="{{ companyURI "/invoices/new" }}"
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>
<button formnovalidate formaction="{{ companyURI "/invoices/new" }}"
id="recompute-button"
name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="action" value="add"
type="submit">{{( pgettext "New invoice" "action" )}}</button>
</fieldset>
</form>
</section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }}

View File

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

View File

@ -19,20 +19,6 @@
{{ define "content" }}
{{- /*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>
<thead>
<tr>
@ -51,7 +37,7 @@
<td>
{{- range $index, $tag := .Tags }}
{{- if gt $index 0 }}, {{ end -}}
<a href="?tags={{ . }}" data-hx-target="main" data-hx-boost="true">{{ . }}</a>
<a href="?tag={{ . }}" data-hx-target="main" data-hx-boost="true">{{ . }}</a>
{{- end }}
</td>
<td class="numeric">{{ .Price | formatPrice }}</td>

View File

@ -1,11 +0,0 @@
{{ 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 }}