Compare commits

...

3 Commits

Author SHA1 Message Date
jordi fita mas 4903c8a3b9 Add the form to add products to an invoice and create invoices too
Still missing: the invoice number, that requires more tables and
possibly a PL/pgSQL function to do it properly.
2023-02-12 21:06:48 +01:00
jordi fita mas 72fbed68ac Add a missing StatusUnprocessableEntity HTTP response code 2023-02-12 21:03:46 +01:00
jordi fita mas c2d8006748 Make FormValidator.CheckValidDate method public 2023-02-12 21:01:20 +01:00
13 changed files with 778 additions and 116 deletions

View File

@ -270,7 +270,7 @@ func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale)) validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
} }
if form.Web.Val != "" { if form.Web.Val != "" {
validator.checkValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale)) validator.CheckValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale))
} }
validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale)) validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale)) validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))

View File

@ -96,6 +96,14 @@ func (c *Conn) MustExec(ctx context.Context, sql string, args ...interface{}) {
} }
} }
func (c *Conn) MustQuery(ctx context.Context, sql string, args ...interface{}) pgx.Rows {
rows, err := c.Conn.Query(ctx, sql, args...)
if err != nil {
panic(err)
}
return rows
}
type Tx struct { type Tx struct {
pgx.Tx pgx.Tx
} }

View File

@ -5,6 +5,7 @@ import (
"database/sql/driver" "database/sql/driver"
"errors" "errors"
"fmt" "fmt"
"github.com/jackc/pgtype"
"html/template" "html/template"
"net/http" "net/http"
"net/mail" "net/mail"
@ -12,6 +13,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Attribute struct { type Attribute struct {
@ -72,6 +74,14 @@ func (field *SelectField) Scan(value interface{}) error {
field.Selected = append(field.Selected, "") field.Selected = append(field.Selected, "")
return nil return nil
} }
if str, ok := value.(string); ok {
if array, err := pgtype.ParseUntypedTextArray(str); err == nil {
for _, element := range array.Elements {
field.Selected = append(field.Selected, element)
}
return nil
}
}
field.Selected = append(field.Selected, fmt.Sprintf("%v", value)) field.Selected = append(field.Selected, fmt.Sprintf("%v", value))
return nil return nil
} }
@ -180,11 +190,16 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
return v.checkSelect(field, field.HasValidOptions(), message) return v.checkSelect(field, field.HasValidOptions(), message)
} }
func (v *FormValidator) checkValidURL(field *InputField, message string) bool { func (v *FormValidator) CheckValidURL(field *InputField, message string) bool {
_, err := url.ParseRequestURI(field.Val) _, err := url.ParseRequestURI(field.Val)
return v.checkInput(field, err == nil, message) return v.checkInput(field, err == nil, message)
} }
func (v *FormValidator) CheckValidDate(field *InputField, message string) bool {
_, err := time.Parse("2006-02-01", field.Val)
return v.checkInput(field, err == nil, message)
}
func (v *FormValidator) CheckValidPostalCode(ctx context.Context, conn *Conn, field *InputField, country string, message string) bool { func (v *FormValidator) CheckValidPostalCode(ctx context.Context, conn *Conn, field *InputField, country string, message string) bool {
pattern := "^" + conn.MustGetText(ctx, ".{1,255}", "select postal_code_regex from country where country_code = $1", country) + "$" pattern := "^" + conn.MustGetText(ctx, ".{1,255}", "select postal_code_regex from country where country_code = $1", country) + "$"
match, err := regexp.MatchString(pattern, field.Val) match, err := regexp.MatchString(pattern, field.Val)

View File

@ -3,20 +3,59 @@ package pkg
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgx/v4"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"html/template" "html/template"
"math"
"net/http" "net/http"
"strconv"
"time" "time"
) )
type InvoiceEntry struct {
Slug string
Date time.Time
Number string
CustomerName string
CustomerSlug string
Status string
StatusLabel string
}
type InvoicesIndexPage struct { type InvoicesIndexPage struct {
Invoices []*InvoiceEntry
} }
func IndexInvoices(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func IndexInvoices(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
page := &InvoicesIndexPage{} page := &InvoicesIndexPage{
Invoices: mustGetInvoiceEntries(r.Context(), getConn(r), mustGetCompany(r), getLocale(r)),
}
mustRenderAppTemplate(w, r, "invoices/index.gohtml", page) mustRenderAppTemplate(w, r, "invoices/index.gohtml", page)
} }
func mustGetInvoiceEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale) []*InvoiceEntry {
rows, err := conn.Query(ctx, "select invoice.slug, invoice_date, invoice_number, contact.business_name, contact.slug, invoice.invoice_status, isi18n.name from invoice join contact using (contact_id) join invoice_status_i18n isi18n on invoice.invoice_status = isi18n.invoice_status and isi18n.lang_tag = $2 where invoice.company_id = $1 order by invoice_date, invoice_number", company.Id, locale.Language.String())
if err != nil {
panic(err)
}
defer rows.Close()
var entries []*InvoiceEntry
for rows.Next() {
entry := &InvoiceEntry{}
err = rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.CustomerSlug, &entry.Status, &entry.StatusLabel)
if err != nil {
panic(err)
}
entries = append(entries, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return entries
}
func GetInvoiceForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func GetInvoiceForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
@ -36,19 +75,123 @@ func mustRenderNewInvoiceForm(w http.ResponseWriter, r *http.Request, form *invo
mustRenderAppTemplate(w, r, "invoices/new.gohtml", form) mustRenderAppTemplate(w, r, "invoices/new.gohtml", form)
} }
func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func mustRenderNewInvoiceProductsForm(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
r.ParseForm() conn := getConn(r)
w.Write([]byte("OK")) company := mustGetCompany(r)
page := newInvoiceProductsPage{
Form: form,
Products: mustGetProductChoices(r.Context(), conn, company),
}
mustRenderAppTemplate(w, r, "invoices/products.gohtml", page)
} }
type invoiceProductForm struct { func mustGetProductChoices(ctx context.Context, conn *Conn, company *Company) []*productChoice {
ProductId *InputField rows := conn.MustQuery(ctx, "select product.product_id, product.name, to_price(price, decimal_digits) from product join company using (company_id) join currency using (currency_code) where company_id = $1 order by name", company.Id)
Name *InputField defer rows.Close()
Description *InputField
Price *InputField var choices []*productChoice
Quantity *InputField for rows.Next() {
Discount *InputField entry := &productChoice{}
Tax *SelectField if err := rows.Scan(&entry.Id, &entry.Name, &entry.Price); err != nil {
panic(err)
}
choices = append(choices, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return choices
}
type newInvoiceProductsPage struct {
Form *invoiceForm
Products []*productChoice
}
type productChoice struct {
Id int
Name string
Price string
}
func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newInvoiceForm(r.Context(), conn, locale, company)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
switch r.Form.Get("action") {
case "update":
form.Update()
w.WriteHeader(http.StatusOK)
mustRenderNewInvoiceForm(w, r, form)
case "products":
w.WriteHeader(http.StatusOK)
mustRenderNewInvoiceProductsForm(w, r, form)
case "add":
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderNewInvoiceForm(w, r, form)
return
}
tx := conn.MustBegin(r.Context())
invoiceId := tx.MustGetInteger(r.Context(), "insert into invoice (company_id, invoice_number, invoice_date, contact_id, notes, currency_code) select company_id, $2, $3, $4, $5, currency_code from company join currency using (currency_code) where company_id = $1 returning invoice_id", company.Id, form.Number, form.Date, form.Customer, form.Notes)
batch := &pgx.Batch{}
for _, product := range form.Products {
batch.Queue("insert into invoice_product(invoice_id, product_id, name, description, price, quantity, discount_rate) select $2, $3, $4, $5, parse_price($6, decimal_digits), $7, $8 / 100::decimal from company join currency using (currency_code) where company_id = $1", company.Id, invoiceId, product.ProductId, product.Name, product.Description, product.Price, product.Quantity.Integer(), product.Discount.Integer())
}
br := tx.SendBatch(r.Context(), batch)
for range form.Products {
if _, err := br.Exec(); err != nil {
panic(err)
}
}
if err := br.Close(); err != nil {
panic(err)
}
tx.MustCommit(r.Context())
http.Redirect(w, r, companyURI(company, "/invoices"), http.StatusSeeOther)
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
}
func HandleAddProductsToInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newInvoiceForm(r.Context(), conn, locale, company)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
index := len(form.Products)
productsId := r.Form["id"]
rows := conn.MustQuery(r.Context(), "select product_id, name, description, to_price(price, decimal_digits), 1 as quantity, 0 as discount, array_agg(tax_id) from product join company using (company_id) join currency using (currency_code) left join product_tax using (product_id) where product_id = any ($1) group by product_id, name, description, price, decimal_digits", productsId)
defer rows.Close()
for rows.Next() {
product := newInvoiceProductForm(index, company, locale, form.Tax.Options)
if err := rows.Scan(product.ProductId, product.Name, product.Description, product.Price, product.Quantity, product.Discount, product.Tax); err != nil {
panic(err)
}
form.Products = append(form.Products, product)
index++
}
if rows.Err() != nil {
panic(rows.Err())
}
w.WriteHeader(http.StatusOK)
mustRenderNewInvoiceForm(w, r, form)
} }
type invoiceForm struct { type invoiceForm struct {
@ -58,6 +201,7 @@ type invoiceForm struct {
Number *InputField Number *InputField
Date *InputField Date *InputField
Notes *InputField Notes *InputField
Tax *SelectField
Products []*invoiceProductForm Products []*invoiceProductForm
} }
@ -88,33 +232,103 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "Notes", locale), Label: pgettext("input", "Notes", locale),
Type: "textarea", Type: "textarea",
}, },
Products: []*invoiceProductForm{ Tax: &SelectField{
newInvoiceProductForm(ctx, conn, company, locale), Name: "text",
Label: pgettext("input", "Taxes", locale),
Multiple: true,
Options: mustGetTaxOptions(ctx, conn, company),
}, },
} }
} }
func newInvoiceProductForm(ctx context.Context, conn *Conn, company *Company, locale *Locale) *invoiceProductForm { func (form *invoiceForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Customer.FillValue(r)
form.Number.FillValue(r)
form.Date.FillValue(r)
form.Notes.FillValue(r)
if r.Form.Has("product.id.0") {
for index := 0; r.Form.Has("product.id." + strconv.Itoa(index)); index++ {
productForm := newInvoiceProductForm(index, form.company, form.locale, form.Tax.Options)
if err := productForm.Parse(r); err != nil {
return err
}
form.Products = append(form.Products, productForm)
}
}
return nil
}
func (form *invoiceForm) Validate() bool {
validator := newFormValidator()
validator.CheckValidSelectOption(form.Customer, gettext("Name can not be empty.", form.locale))
if validator.CheckRequiredInput(form.Date, gettext("Invoice date can not be empty.", form.locale)) {
validator.CheckValidDate(form.Date, gettext("Invoice date must be a valid date.", form.locale))
}
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
allOK := validator.AllOK()
for _, product := range form.Products {
allOK = product.Validate() && allOK
}
return allOK
}
func (form *invoiceForm) Update() {
products := form.Products
form.Products = nil
index := 0
for _, product := range products {
if product.Quantity.Val != "0" {
form.Products = append(form.Products, product)
index++
}
}
}
func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*SelectOption {
return MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id)
}
type invoiceProductForm struct {
locale *Locale
company *Company
ProductId *InputField
Name *InputField
Description *InputField
Price *InputField
Quantity *InputField
Discount *InputField
Tax *SelectField
}
func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptions []*SelectOption) *invoiceProductForm {
suffix := "." + strconv.Itoa(index)
return &invoiceProductForm{ return &invoiceProductForm{
locale: locale,
company: company,
ProductId: &InputField{ ProductId: &InputField{
Name: "product[][id]", Name: "product.id" + suffix,
Label: pgettext("input", "Id", locale), Label: pgettext("input", "Id", locale),
Type: "hidden", Type: "hidden",
Required: true, Required: true,
}, },
Name: &InputField{ Name: &InputField{
Name: "product[][name]", Name: "product.name" + suffix,
Label: pgettext("input", "Name", locale), Label: pgettext("input", "Name", locale),
Type: "text", Type: "text",
Required: true, Required: true,
}, },
Description: &InputField{ Description: &InputField{
Name: "product[][description]", Name: "product.description" + suffix,
Label: pgettext("input", "Description", locale), Label: pgettext("input", "Description", locale),
Type: "textarea", Type: "textarea",
}, },
Price: &InputField{ Price: &InputField{
Name: "product[][price]", Name: "product.price" + suffix,
Label: pgettext("input", "Price", locale), Label: pgettext("input", "Price", locale),
Type: "number", Type: "number",
Required: true, Required: true,
@ -124,7 +338,7 @@ func newInvoiceProductForm(ctx context.Context, conn *Conn, company *Company, lo
}, },
}, },
Quantity: &InputField{ Quantity: &InputField{
Name: "product[][quantity]", Name: "product.quantity" + suffix,
Label: pgettext("input", "Quantity", locale), Label: pgettext("input", "Quantity", locale),
Type: "number", Type: "number",
Required: true, Required: true,
@ -133,7 +347,7 @@ func newInvoiceProductForm(ctx context.Context, conn *Conn, company *Company, lo
}, },
}, },
Discount: &InputField{ Discount: &InputField{
Name: "product[][discount]", Name: "product.discount" + suffix,
Label: pgettext("input", "Discount (%)", locale), Label: pgettext("input", "Discount (%)", locale),
Type: "number", Type: "number",
Required: true, Required: true,
@ -143,10 +357,40 @@ func newInvoiceProductForm(ctx context.Context, conn *Conn, company *Company, lo
}, },
}, },
Tax: &SelectField{ Tax: &SelectField{
Name: "product[][tax]", Name: "product.tax" + suffix,
Label: pgettext("input", "Taxes", locale), Label: pgettext("input", "Taxes", locale),
Multiple: true, Multiple: true,
Options: MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id), Options: taxOptions,
}, },
} }
} }
func (form *invoiceProductForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.ProductId.FillValue(r)
form.Name.FillValue(r)
form.Description.FillValue(r)
form.Price.FillValue(r)
form.Quantity.FillValue(r)
form.Discount.FillValue(r)
form.Tax.FillValue(r)
return nil
}
func (form *invoiceProductForm) Validate() bool {
validator := newFormValidator()
validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale))
if validator.CheckRequiredInput(form.Price, gettext("Price can not be empty.", form.locale)) {
validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, gettext("Price must be a number greater than zero.", form.locale))
}
if validator.CheckRequiredInput(form.Quantity, gettext("Quantity can not be empty.", form.locale)) {
validator.CheckValidInteger(form.Quantity, 1, math.MaxInt, gettext("Quantity must be a number greater than zero.", form.locale))
}
if validator.CheckRequiredInput(form.Discount, gettext("Discount can not be empty.", form.locale)) {
validator.CheckValidInteger(form.Discount, 0, 100, gettext("Discount must be a percentage between 0 and 100.", form.locale))
}
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
return validator.AllOK()
}

View File

@ -124,6 +124,7 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
return return
} }
if !form.Validate() { if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderEditProductForm(w, r, form) mustRenderEditProductForm(w, r, form)
return return
} }
@ -213,7 +214,7 @@ func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Name: "tax", Name: "tax",
Label: pgettext("input", "Taxes", locale), Label: pgettext("input", "Taxes", locale),
Multiple: true, Multiple: true,
Options: MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id), Options: mustGetTaxOptions(ctx, conn, company),
}, },
} }
} }

View File

@ -25,6 +25,7 @@ func NewRouter(db *Db) http.Handler {
companyRouter.GET("/invoices", IndexInvoices) companyRouter.GET("/invoices", IndexInvoices)
companyRouter.POST("/invoices", HandleAddInvoice) companyRouter.POST("/invoices", HandleAddInvoice)
companyRouter.GET("/invoices/:slug", GetInvoiceForm) companyRouter.GET("/invoices/:slug", GetInvoiceForm)
companyRouter.POST("/invoices/new/products", HandleAddProductsToInvoice)
companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderAppTemplate(w, r, "dashboard.gohtml", nil) mustRenderAppTemplate(w, r, "dashboard.gohtml", nil)
}) })

View File

@ -9,6 +9,7 @@ import (
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"time"
) )
const overrideMethodName = "_method" const overrideMethodName = "_method"
@ -39,6 +40,9 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
} }
return p.Sprintf("%.*f", company.DecimalDigits, number.Decimal(f)) return p.Sprintf("%.*f", company.DecimalDigits, number.Decimal(f))
}, },
"formatDate": func(time time.Time) string {
return time.Format("02/01/2006")
},
"csrfToken": func() template.HTML { "csrfToken": func() template.HTML {
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken)) return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken))
}, },

230
po/ca.po
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-02-08 13:43+0100\n" "POT-Creation-Date: 2023-02-12 20:51+0100\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"
@ -17,6 +17,111 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: web/template/invoices/products.gohtml:2
#: web/template/invoices/products.gohtml:15
msgctxt "title"
msgid "Add Products to Invoice"
msgstr "Afegeix productes a la factura"
#: web/template/invoices/products.gohtml:9 web/template/invoices/new.gohtml:9
#: web/template/invoices/index.gohtml:8 web/template/contacts/new.gohtml:9
#: web/template/contacts/index.gohtml:8 web/template/contacts/edit.gohtml:9
#: web/template/profile.gohtml:9 web/template/tax-details.gohtml:8
#: web/template/products/new.gohtml:9 web/template/products/index.gohtml:8
#: web/template/products/edit.gohtml:9
msgctxt "title"
msgid "Home"
msgstr "Inici"
#: web/template/invoices/products.gohtml:10 web/template/invoices/new.gohtml:10
#: web/template/invoices/index.gohtml:2 web/template/invoices/index.gohtml:9
msgctxt "title"
msgid "Invoices"
msgstr "Factures"
#: web/template/invoices/products.gohtml:11 web/template/invoices/new.gohtml:2
#: web/template/invoices/new.gohtml:11 web/template/invoices/new.gohtml:15
msgctxt "title"
msgid "New Invoice"
msgstr "Nova factura"
#: web/template/invoices/products.gohtml:41
#: web/template/products/index.gohtml:21
msgctxt "product"
msgid "All"
msgstr "Tots"
#: web/template/invoices/products.gohtml:42
#: web/template/products/index.gohtml:22
msgctxt "title"
msgid "Name"
msgstr "Nom"
#: web/template/invoices/products.gohtml:43
#: web/template/products/index.gohtml:23
msgctxt "title"
msgid "Price"
msgstr "Preu"
#: web/template/invoices/products.gohtml:57
#: web/template/products/index.gohtml:37
msgid "No products added yet."
msgstr "No hi ha cap producte."
#: web/template/invoices/products.gohtml:64 web/template/invoices/new.gohtml:37
msgctxt "action"
msgid "Add products"
msgstr "Afegeix productes"
#: web/template/invoices/new.gohtml:38
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/template/invoices/new.gohtml:40 web/template/invoices/index.gohtml:13
msgctxt "action"
msgid "New invoice"
msgstr "Nova factura"
#: web/template/invoices/index.gohtml:21
msgctxt "invoice"
msgid "All"
msgstr "Totes"
#: web/template/invoices/index.gohtml:22
msgctxt "title"
msgid "Date"
msgstr "Data"
#: web/template/invoices/index.gohtml:23
msgctxt "title"
msgid "Invoice Num."
msgstr "Núm. factura"
#: web/template/invoices/index.gohtml:24 web/template/contacts/index.gohtml:22
msgctxt "title"
msgid "Customer"
msgstr "Client"
#: web/template/invoices/index.gohtml:25
msgctxt "title"
msgid "Status"
msgstr "Estat"
#: web/template/invoices/index.gohtml:26
msgctxt "title"
msgid "Label"
msgstr "Etiqueta"
#: web/template/invoices/index.gohtml:27
msgctxt "title"
msgid "Download"
msgstr "Descàrrega"
#: web/template/invoices/index.gohtml:45
msgid "No invoices added yet."
msgstr "No hi ha cap factura."
#: web/template/dashboard.gohtml:2 #: web/template/dashboard.gohtml:2
msgctxt "title" msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
@ -44,10 +149,15 @@ msgstr "Tauler"
#: web/template/app.gohtml:44 #: web/template/app.gohtml:44
msgctxt "nav" msgctxt "nav"
msgid "Invoices"
msgstr "Factures"
#: web/template/app.gohtml:45
msgctxt "nav"
msgid "Products" msgid "Products"
msgstr "Productes" msgstr "Productes"
#: web/template/app.gohtml:45 #: web/template/app.gohtml:46
msgctxt "nav" msgctxt "nav"
msgid "Contacts" msgid "Contacts"
msgstr "Contactes" msgstr "Contactes"
@ -58,14 +168,6 @@ msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nou contacte" msgstr "Nou contacte"
#: web/template/contacts/new.gohtml:9 web/template/contacts/index.gohtml:8
#: web/template/contacts/edit.gohtml:9 web/template/profile.gohtml:9
#: web/template/tax-details.gohtml:8 web/template/products/new.gohtml:9
#: web/template/products/index.gohtml:8 web/template/products/edit.gohtml:9
msgctxt "title"
msgid "Home"
msgstr "Inici"
#: web/template/contacts/new.gohtml:10 web/template/contacts/index.gohtml:2 #: web/template/contacts/new.gohtml:10 web/template/contacts/index.gohtml:2
#: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10 #: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10
msgctxt "title" msgctxt "title"
@ -82,11 +184,6 @@ msgctxt "contact"
msgid "All" msgid "All"
msgstr "Tots" msgstr "Tots"
#: web/template/contacts/index.gohtml:22
msgctxt "title"
msgid "Customer"
msgstr "Client"
#: web/template/contacts/index.gohtml:23 #: web/template/contacts/index.gohtml:23
msgctxt "title" msgctxt "title"
msgid "Email" msgid "Email"
@ -199,25 +296,6 @@ msgctxt "action"
msgid "New product" msgid "New product"
msgstr "Nou producte" msgstr "Nou producte"
#: web/template/products/index.gohtml:21
msgctxt "product"
msgid "All"
msgstr "Tots"
#: web/template/products/index.gohtml:22
msgctxt "title"
msgid "Name"
msgstr "Nom"
#: web/template/products/index.gohtml:23
msgctxt "title"
msgid "Price"
msgstr "Preu"
#: web/template/products/index.gohtml:37
msgid "No products added yet."
msgstr "No hi ha cap producte."
#: web/template/products/edit.gohtml:2 web/template/products/edit.gohtml:15 #: web/template/products/edit.gohtml:2 web/template/products/edit.gohtml:15
msgctxt "title" msgctxt "title"
msgid "Edit Product “%s”" msgid "Edit Product “%s”"
@ -254,39 +332,40 @@ msgstr "No podeu deixar la contrasenya en blanc."
msgid "Invalid user or password." msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes." msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/products.go:193 #: pkg/products.go:194 pkg/invoices.go:321
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: pkg/products.go:199 #: pkg/products.go:200 pkg/invoices.go:327
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripció" msgstr "Descripció"
#: pkg/products.go:204 #: pkg/products.go:205 pkg/invoices.go:332
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Preu" msgstr "Preu"
#: pkg/products.go:214 #: pkg/products.go:215 pkg/invoices.go:237 pkg/invoices.go:361
msgctxt "input" msgctxt "input"
msgid "Taxes" msgid "Taxes"
msgstr "Imposts" msgstr "Imposts"
#: pkg/products.go:234 pkg/profile.go:92 #: pkg/products.go:235 pkg/profile.go:92 pkg/invoices.go:267
#: pkg/invoices.go:384
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." msgstr "No podeu deixar el nom en blanc."
#: pkg/products.go:235 #: pkg/products.go:236 pkg/invoices.go:385
msgid "Price can not be empty." msgid "Price can not be empty."
msgstr "No podeu deixar el preu en blanc." msgstr "No podeu deixar el preu en blanc."
#: pkg/products.go:236 #: pkg/products.go:237 pkg/invoices.go:386
msgid "Price must be a number greater than zero." msgid "Price must be a number greater than zero."
msgstr "El preu ha de ser un número major a zero." msgstr "El preu ha de ser un número major a zero."
#: pkg/products.go:238 #: pkg/products.go:239 pkg/invoices.go:271 pkg/invoices.go:394
msgid "Selected tax is not valid." msgid "Selected tax is not valid."
msgstr "Heu seleccionat un impost que no és vàlid." msgstr "Heu seleccionat un impost que no és vàlid."
@ -349,6 +428,73 @@ 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/invoices.go:66
msgid "Select a customer to bill."
msgstr "Escolliu un client a facturar."
#: pkg/invoices.go:163
msgid "Invalid action"
msgstr "Acció invàlida."
#: pkg/invoices.go:214
msgctxt "input"
msgid "Customer"
msgstr "Client"
#: pkg/invoices.go:220
msgctxt "input"
msgid "Number"
msgstr "Número"
#: pkg/invoices.go:226
msgctxt "input"
msgid "Invoice Date"
msgstr "Data de factura"
#: pkg/invoices.go:232
msgctxt "input"
msgid "Notes"
msgstr "Notes"
#: pkg/invoices.go:268
msgid "Invoice date can not be empty."
msgstr "No podeu deixar la data de la factura en blanc."
#: pkg/invoices.go:269
msgid "Invoice date must be a valid date."
msgstr "La data de facturació ha de ser vàlida."
#: pkg/invoices.go:315
msgctxt "input"
msgid "Id"
msgstr "Identificador"
#: pkg/invoices.go:342
msgctxt "input"
msgid "Quantity"
msgstr "Quantitat"
#: pkg/invoices.go:351
msgctxt "input"
msgid "Discount (%)"
msgstr "Descompte (%)"
#: pkg/invoices.go:388
msgid "Quantity can not be empty."
msgstr "No podeu deixar la quantitat en blanc."
#: pkg/invoices.go:389
msgid "Quantity must be a number greater than zero."
msgstr "La quantitat ha de ser un número major a zero."
#: pkg/invoices.go:391
msgid "Discount can not be empty."
msgstr "No podeu deixar el descompte en blanc."
#: pkg/invoices.go:392
msgid "Discount must be a percentage between 0 and 100."
msgstr "El descompte ha de ser un percentatge entre 0 i 100."
#: pkg/contacts.go:149 #: pkg/contacts.go:149
msgctxt "input" msgctxt "input"
msgid "Business name" msgid "Business name"

230
po/es.po
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-02-08 13:43+0100\n" "POT-Creation-Date: 2023-02-12 20:51+0100\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"
@ -17,6 +17,111 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: web/template/invoices/products.gohtml:2
#: web/template/invoices/products.gohtml:15
msgctxt "title"
msgid "Add Products to Invoice"
msgstr "Añadir productos a la factura"
#: web/template/invoices/products.gohtml:9 web/template/invoices/new.gohtml:9
#: web/template/invoices/index.gohtml:8 web/template/contacts/new.gohtml:9
#: web/template/contacts/index.gohtml:8 web/template/contacts/edit.gohtml:9
#: web/template/profile.gohtml:9 web/template/tax-details.gohtml:8
#: web/template/products/new.gohtml:9 web/template/products/index.gohtml:8
#: web/template/products/edit.gohtml:9
msgctxt "title"
msgid "Home"
msgstr "Inicio"
#: web/template/invoices/products.gohtml:10 web/template/invoices/new.gohtml:10
#: web/template/invoices/index.gohtml:2 web/template/invoices/index.gohtml:9
msgctxt "title"
msgid "Invoices"
msgstr "Facturas"
#: web/template/invoices/products.gohtml:11 web/template/invoices/new.gohtml:2
#: web/template/invoices/new.gohtml:11 web/template/invoices/new.gohtml:15
msgctxt "title"
msgid "New Invoice"
msgstr "Nueva factura"
#: web/template/invoices/products.gohtml:41
#: web/template/products/index.gohtml:21
msgctxt "product"
msgid "All"
msgstr "Todos"
#: web/template/invoices/products.gohtml:42
#: web/template/products/index.gohtml:22
msgctxt "title"
msgid "Name"
msgstr "Nombre"
#: web/template/invoices/products.gohtml:43
#: web/template/products/index.gohtml:23
msgctxt "title"
msgid "Price"
msgstr "Precio"
#: web/template/invoices/products.gohtml:57
#: web/template/products/index.gohtml:37
msgid "No products added yet."
msgstr "No hay productos."
#: web/template/invoices/products.gohtml:64 web/template/invoices/new.gohtml:37
msgctxt "action"
msgid "Add products"
msgstr "Añadir productos"
#: web/template/invoices/new.gohtml:38
msgctxt "action"
msgid "Update"
msgstr "Actualizar"
#: web/template/invoices/new.gohtml:40 web/template/invoices/index.gohtml:13
msgctxt "action"
msgid "New invoice"
msgstr "Nueva factura"
#: web/template/invoices/index.gohtml:21
msgctxt "invoice"
msgid "All"
msgstr "Todas"
#: web/template/invoices/index.gohtml:22
msgctxt "title"
msgid "Date"
msgstr "Fecha"
#: web/template/invoices/index.gohtml:23
msgctxt "title"
msgid "Invoice Num."
msgstr "Nº factura"
#: web/template/invoices/index.gohtml:24 web/template/contacts/index.gohtml:22
msgctxt "title"
msgid "Customer"
msgstr "Cliente"
#: web/template/invoices/index.gohtml:25
msgctxt "title"
msgid "Status"
msgstr "Estado"
#: web/template/invoices/index.gohtml:26
msgctxt "title"
msgid "Label"
msgstr "Etiqueta"
#: web/template/invoices/index.gohtml:27
msgctxt "title"
msgid "Download"
msgstr "Descargar"
#: web/template/invoices/index.gohtml:45
msgid "No invoices added yet."
msgstr "No hay facturas."
#: web/template/dashboard.gohtml:2 #: web/template/dashboard.gohtml:2
msgctxt "title" msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
@ -44,10 +149,15 @@ msgstr "Panel"
#: web/template/app.gohtml:44 #: web/template/app.gohtml:44
msgctxt "nav" msgctxt "nav"
msgid "Invoices"
msgstr "Facturas"
#: web/template/app.gohtml:45
msgctxt "nav"
msgid "Products" msgid "Products"
msgstr "Productos" msgstr "Productos"
#: web/template/app.gohtml:45 #: web/template/app.gohtml:46
msgctxt "nav" msgctxt "nav"
msgid "Contacts" msgid "Contacts"
msgstr "Contactos" msgstr "Contactos"
@ -58,14 +168,6 @@ msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nuevo contacto" msgstr "Nuevo contacto"
#: web/template/contacts/new.gohtml:9 web/template/contacts/index.gohtml:8
#: web/template/contacts/edit.gohtml:9 web/template/profile.gohtml:9
#: web/template/tax-details.gohtml:8 web/template/products/new.gohtml:9
#: web/template/products/index.gohtml:8 web/template/products/edit.gohtml:9
msgctxt "title"
msgid "Home"
msgstr "Inicio"
#: web/template/contacts/new.gohtml:10 web/template/contacts/index.gohtml:2 #: web/template/contacts/new.gohtml:10 web/template/contacts/index.gohtml:2
#: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10 #: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10
msgctxt "title" msgctxt "title"
@ -82,11 +184,6 @@ msgctxt "contact"
msgid "All" msgid "All"
msgstr "Todos" msgstr "Todos"
#: web/template/contacts/index.gohtml:22
msgctxt "title"
msgid "Customer"
msgstr "Cliente"
#: web/template/contacts/index.gohtml:23 #: web/template/contacts/index.gohtml:23
msgctxt "title" msgctxt "title"
msgid "Email" msgid "Email"
@ -199,25 +296,6 @@ msgctxt "action"
msgid "New product" msgid "New product"
msgstr "Nuevo producto" msgstr "Nuevo producto"
#: web/template/products/index.gohtml:21
msgctxt "product"
msgid "All"
msgstr "Todos"
#: web/template/products/index.gohtml:22
msgctxt "title"
msgid "Name"
msgstr "Nombre"
#: web/template/products/index.gohtml:23
msgctxt "title"
msgid "Price"
msgstr "Precio"
#: web/template/products/index.gohtml:37
msgid "No products added yet."
msgstr "No hay productos."
#: web/template/products/edit.gohtml:2 web/template/products/edit.gohtml:15 #: web/template/products/edit.gohtml:2 web/template/products/edit.gohtml:15
msgctxt "title" msgctxt "title"
msgid "Edit Product “%s”" msgid "Edit Product “%s”"
@ -254,39 +332,40 @@ msgstr "No podéis dejar la contraseña en blanco."
msgid "Invalid user or password." msgid "Invalid user or password."
msgstr "Nombre de usuario o contraseña inválido." msgstr "Nombre de usuario o contraseña inválido."
#: pkg/products.go:193 #: pkg/products.go:194 pkg/invoices.go:321
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: pkg/products.go:199 #: pkg/products.go:200 pkg/invoices.go:327
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripción" msgstr "Descripción"
#: pkg/products.go:204 #: pkg/products.go:205 pkg/invoices.go:332
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Precio" msgstr "Precio"
#: pkg/products.go:214 #: pkg/products.go:215 pkg/invoices.go:237 pkg/invoices.go:361
msgctxt "input" msgctxt "input"
msgid "Taxes" msgid "Taxes"
msgstr "Impuestos" msgstr "Impuestos"
#: pkg/products.go:234 pkg/profile.go:92 #: pkg/products.go:235 pkg/profile.go:92 pkg/invoices.go:267
#: pkg/invoices.go:384
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." msgstr "No podéis dejar el nombre en blanco."
#: pkg/products.go:235 #: pkg/products.go:236 pkg/invoices.go:385
msgid "Price can not be empty." msgid "Price can not be empty."
msgstr "No podéis dejar el precio en blanco." msgstr "No podéis dejar el precio en blanco."
#: pkg/products.go:236 #: pkg/products.go:237 pkg/invoices.go:386
msgid "Price must be a number greater than zero." msgid "Price must be a number greater than zero."
msgstr "El precio tiene que ser un número mayor a cero." msgstr "El precio tiene que ser un número mayor a cero."
#: pkg/products.go:238 #: pkg/products.go:239 pkg/invoices.go:271 pkg/invoices.go:394
msgid "Selected tax is not valid." msgid "Selected tax is not valid."
msgstr "Habéis escogido un impuesto que no es válido." msgstr "Habéis escogido un impuesto que no es válido."
@ -349,6 +428,73 @@ 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/invoices.go:66
msgid "Select a customer to bill."
msgstr "Escoged un cliente a facturar."
#: pkg/invoices.go:163
msgid "Invalid action"
msgstr "Acción inválida."
#: pkg/invoices.go:214
msgctxt "input"
msgid "Customer"
msgstr "Cliente"
#: pkg/invoices.go:220
msgctxt "input"
msgid "Number"
msgstr "Número"
#: pkg/invoices.go:226
msgctxt "input"
msgid "Invoice Date"
msgstr "Fecha de factura"
#: pkg/invoices.go:232
msgctxt "input"
msgid "Notes"
msgstr "Notas"
#: pkg/invoices.go:268
msgid "Invoice date can not be empty."
msgstr "No podéis dejar la fecha de la factura en blanco."
#: pkg/invoices.go:269
msgid "Invoice date must be a valid date."
msgstr "La fecha de factura debe ser válida."
#: pkg/invoices.go:315
msgctxt "input"
msgid "Id"
msgstr "Identificador"
#: pkg/invoices.go:342
msgctxt "input"
msgid "Quantity"
msgstr "Cantidad"
#: pkg/invoices.go:351
msgctxt "input"
msgid "Discount (%)"
msgstr "Descuento (%)"
#: pkg/invoices.go:388
msgid "Quantity can not be empty."
msgstr "No podéis dejar la cantidad en blanco."
#: pkg/invoices.go:389
msgid "Quantity must be a number greater than zero."
msgstr "La cantidad tiene que ser un número mayor a cero."
#: pkg/invoices.go:391
msgid "Discount can not be empty."
msgstr "No podéis dejar el descuento en blanco."
#: pkg/invoices.go:392
msgid "Discount must be a percentage between 0 and 100."
msgstr "El descuento tiene que ser un percentage entre 0 y 100."
#: pkg/contacts.go:149 #: pkg/contacts.go:149
msgctxt "input" msgctxt "input"
msgid "Business name" msgid "Business name"

View File

@ -1,10 +1,12 @@
{{ define "hidden-field" -}} {{ define "hidden-field" -}}
<input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field" {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}}
<input type="hidden" name="{{ .Name }}"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }} {{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
{{ if .Required }}required="required"{{ end }} value="{{ .Val }}"> value="{{ .Val }}">
{{- end }} {{- end }}
{{ define "input-field" -}} {{ define "input-field" -}}
{{- /*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 eq .Type "textarea" }} {{ if eq .Type "textarea" }}
<textarea name="{{ .Name }}" id="{{ .Name }}-field" <textarea name="{{ .Name }}" id="{{ .Name }}-field"
@ -27,7 +29,17 @@
</div> </div>
{{- end }} {{- end }}
{{ define "hidden-select-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.SelectField*/ -}}
{{- range $selected := .Selected }}
<input type="hidden" name="{{ $.Name }}"
{{- range $attribute := $.Attributes }} {{$attribute}} {{ end }}
value="{{ . }}">
{{- end }}
{{- end }}
{{ define "select-field" -}} {{ define "select-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.SelectField*/ -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input {{ if .Errors }}has-errors{{ end }}">
<select id="{{ .Name }}-field" name="{{ .Name }}" <select id="{{ .Name }}-field" name="{{ .Name }}"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end -}} {{- range $attribute := .Attributes }} {{$attribute}} {{ end -}}

View File

@ -28,9 +28,23 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{ with .Invoices }}
{{- range $invoice := . }}
<tr>
<td></td>
<td>{{ .Date|formatDate }}</td>
<td><a href="{{ companyURI "/invoices/"}}{{ .Slug }}">{{ .Number }}</a></td>
<td><a href="{{ companyURI "/contacts/"}}{{ .CustomerSlug }}">{{ .CustomerName }}</a></td>
<td class="invoice-status-{{ .Status }}">{{ .StatusLabel }}</td>
<td></td>
<td></td>
</tr>
{{- end }}
{{ else }}
<tr> <tr>
<td colspan="7">{{( gettext "No invoices added yet." )}}</td> <td colspan="7">{{( gettext "No invoices added yet." )}}</td>
</tr> </tr>
{{ end }}
</tbody> </tbody>
</table> </table>
{{- end }} {{- end }}

View File

@ -34,7 +34,10 @@
{{- end }} {{- end }}
<fieldset> <fieldset>
<button class="primary" name="action" value="add" type="submit">{{( pgettext "New invoice" "action" )}}</button> <button name="action" value="products" type="submit">{{( pgettext "Add products" "action" )}}</button>
<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> </fieldset>
</form> </form>

View File

@ -0,0 +1,68 @@
{{ define "title" -}}
{{( pgettext "Add Products to Invoice" "title" )}}
{{- end }}
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newInvoiceProductsPage*/ -}}
<nav>
<p>
<a href="{{ companyURI "/" }}">{{( pgettext "Home" "title" )}}</a> /
<a href="{{ companyURI "/invoices"}}">{{( pgettext "Invoices" "title" )}}</a> /
<a>{{( pgettext "New Invoice" "title" )}}</a>
</p>
</nav>
<section class="dialog-content">
<h2>{{(pgettext "Add Products to Invoice" "title")}}</h2>
<form method="POST" action="{{ companyURI "/invoices/new/products" }}">
{{ csrfToken }}
{{- with .Form }}
{{ template "hidden-select-field" .Customer }}
{{ template "hidden-field" .Number }}
{{ template "hidden-field" .Date }}
{{ template "hidden-field" .Notes }}
{{- range $product := .Products }}
<fieldset>
{{ template "hidden-field" .ProductId }}
{{ template "hidden-field" .Name }}
{{ template "hidden-field" .Description }}
{{ template "hidden-field" .Price }}
{{ template "hidden-field" .Quantity }}
{{ template "hidden-field" .Discount }}
{{ template "hidden-select-field" .Tax }}
</fieldset>
{{- end }}
{{- end }}
<table>
<thead>
<tr>
<th>{{( pgettext "All" "product" )}}</th>
<th>{{( pgettext "Name" "title" )}}</th>
<th>{{( pgettext "Price" "title" )}}</th>
</tr>
</thead>
<tbody>
{{ with .Products }}
{{- range $product, $key := . }}
<tr>
<td><input type="checkbox" name="id" id="new-product-id-{{$key}}" value="{{.Id}}"></td>
<td><label for="new-product-id-{{$key}}">{{ .Name }}</label></td>
<td>{{ .Price | formatPrice }}</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="4">{{( gettext "No products added yet." )}}</td>
</tr>
{{ end }}
</tbody>
</table>
<fieldset>
<button class="primary" type="submit">{{( pgettext "Add products" "action" )}}</button>
</fieldset>
</form>
</section>
{{- end }}