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))
}
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.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 {
pgx.Tx
}

View File

@ -5,6 +5,7 @@ import (
"database/sql/driver"
"errors"
"fmt"
"github.com/jackc/pgtype"
"html/template"
"net/http"
"net/mail"
@ -12,6 +13,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
)
type Attribute struct {
@ -72,6 +74,14 @@ func (field *SelectField) Scan(value interface{}) error {
field.Selected = append(field.Selected, "")
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))
return nil
}
@ -180,11 +190,16 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
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)
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 {
pattern := "^" + conn.MustGetText(ctx, ".{1,255}", "select postal_code_regex from country where country_code = $1", country) + "$"
match, err := regexp.MatchString(pattern, field.Val)

View File

@ -3,20 +3,59 @@ package pkg
import (
"context"
"fmt"
"github.com/jackc/pgx/v4"
"github.com/julienschmidt/httprouter"
"html/template"
"math"
"net/http"
"strconv"
"time"
)
type InvoiceEntry struct {
Slug string
Date time.Time
Number string
CustomerName string
CustomerSlug string
Status string
StatusLabel string
}
type InvoicesIndexPage struct {
Invoices []*InvoiceEntry
}
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)
}
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) {
locale := getLocale(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)
}
func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
r.ParseForm()
w.Write([]byte("OK"))
func mustRenderNewInvoiceProductsForm(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
conn := getConn(r)
company := mustGetCompany(r)
page := newInvoiceProductsPage{
Form: form,
Products: mustGetProductChoices(r.Context(), conn, company),
}
mustRenderAppTemplate(w, r, "invoices/products.gohtml", page)
}
type invoiceProductForm struct {
ProductId *InputField
Name *InputField
Description *InputField
Price *InputField
Quantity *InputField
Discount *InputField
Tax *SelectField
func mustGetProductChoices(ctx context.Context, conn *Conn, company *Company) []*productChoice {
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)
defer rows.Close()
var choices []*productChoice
for rows.Next() {
entry := &productChoice{}
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 {
@ -58,6 +201,7 @@ type invoiceForm struct {
Number *InputField
Date *InputField
Notes *InputField
Tax *SelectField
Products []*invoiceProductForm
}
@ -88,33 +232,103 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "Notes", locale),
Type: "textarea",
},
Products: []*invoiceProductForm{
newInvoiceProductForm(ctx, conn, company, locale),
Tax: &SelectField{
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{
locale: locale,
company: company,
ProductId: &InputField{
Name: "product[][id]",
Name: "product.id" + suffix,
Label: pgettext("input", "Id", locale),
Type: "hidden",
Required: true,
},
Name: &InputField{
Name: "product[][name]",
Name: "product.name" + suffix,
Label: pgettext("input", "Name", locale),
Type: "text",
Required: true,
},
Description: &InputField{
Name: "product[][description]",
Name: "product.description" + suffix,
Label: pgettext("input", "Description", locale),
Type: "textarea",
},
Price: &InputField{
Name: "product[][price]",
Name: "product.price" + suffix,
Label: pgettext("input", "Price", locale),
Type: "number",
Required: true,
@ -124,7 +338,7 @@ func newInvoiceProductForm(ctx context.Context, conn *Conn, company *Company, lo
},
},
Quantity: &InputField{
Name: "product[][quantity]",
Name: "product.quantity" + suffix,
Label: pgettext("input", "Quantity", locale),
Type: "number",
Required: true,
@ -133,7 +347,7 @@ func newInvoiceProductForm(ctx context.Context, conn *Conn, company *Company, lo
},
},
Discount: &InputField{
Name: "product[][discount]",
Name: "product.discount" + suffix,
Label: pgettext("input", "Discount (%)", locale),
Type: "number",
Required: true,
@ -143,10 +357,40 @@ func newInvoiceProductForm(ctx context.Context, conn *Conn, company *Company, lo
},
},
Tax: &SelectField{
Name: "product[][tax]",
Name: "product.tax" + suffix,
Label: pgettext("input", "Taxes", locale),
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
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderEditProductForm(w, r, form)
return
}
@ -213,7 +214,7 @@ func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Name: "tax",
Label: pgettext("input", "Taxes", locale),
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.POST("/invoices", HandleAddInvoice)
companyRouter.GET("/invoices/:slug", GetInvoiceForm)
companyRouter.POST("/invoices/new/products", HandleAddProductsToInvoice)
companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderAppTemplate(w, r, "dashboard.gohtml", nil)
})

View File

@ -9,6 +9,7 @@ import (
"math"
"net/http"
"strconv"
"time"
)
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))
},
"formatDate": func(time time.Time) string {
return time.Format("02/01/2006")
},
"csrfToken": func() template.HTML {
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 ""
"Project-Id-Version: numerus\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"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -17,6 +17,111 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\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
msgctxt "title"
msgid "Dashboard"
@ -44,10 +149,15 @@ msgstr "Tauler"
#: web/template/app.gohtml:44
msgctxt "nav"
msgid "Invoices"
msgstr "Factures"
#: web/template/app.gohtml:45
msgctxt "nav"
msgid "Products"
msgstr "Productes"
#: web/template/app.gohtml:45
#: web/template/app.gohtml:46
msgctxt "nav"
msgid "Contacts"
msgstr "Contactes"
@ -58,14 +168,6 @@ msgctxt "title"
msgid "New Contact"
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/index.gohtml:9 web/template/contacts/edit.gohtml:10
msgctxt "title"
@ -82,11 +184,6 @@ msgctxt "contact"
msgid "All"
msgstr "Tots"
#: web/template/contacts/index.gohtml:22
msgctxt "title"
msgid "Customer"
msgstr "Client"
#: web/template/contacts/index.gohtml:23
msgctxt "title"
msgid "Email"
@ -199,25 +296,6 @@ msgctxt "action"
msgid "New product"
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
msgctxt "title"
msgid "Edit Product “%s”"
@ -254,39 +332,40 @@ msgstr "No podeu deixar la contrasenya en blanc."
msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/products.go:193
#: pkg/products.go:194 pkg/invoices.go:321
msgctxt "input"
msgid "Name"
msgstr "Nom"
#: pkg/products.go:199
#: pkg/products.go:200 pkg/invoices.go:327
msgctxt "input"
msgid "Description"
msgstr "Descripció"
#: pkg/products.go:204
#: pkg/products.go:205 pkg/invoices.go:332
msgctxt "input"
msgid "Price"
msgstr "Preu"
#: pkg/products.go:214
#: pkg/products.go:215 pkg/invoices.go:237 pkg/invoices.go:361
msgctxt "input"
msgid "Taxes"
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."
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."
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."
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."
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."
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
msgctxt "input"
msgid "Business name"

230
po/es.po
View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\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"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -17,6 +17,111 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\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
msgctxt "title"
msgid "Dashboard"
@ -44,10 +149,15 @@ msgstr "Panel"
#: web/template/app.gohtml:44
msgctxt "nav"
msgid "Invoices"
msgstr "Facturas"
#: web/template/app.gohtml:45
msgctxt "nav"
msgid "Products"
msgstr "Productos"
#: web/template/app.gohtml:45
#: web/template/app.gohtml:46
msgctxt "nav"
msgid "Contacts"
msgstr "Contactos"
@ -58,14 +168,6 @@ msgctxt "title"
msgid "New Contact"
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/index.gohtml:9 web/template/contacts/edit.gohtml:10
msgctxt "title"
@ -82,11 +184,6 @@ msgctxt "contact"
msgid "All"
msgstr "Todos"
#: web/template/contacts/index.gohtml:22
msgctxt "title"
msgid "Customer"
msgstr "Cliente"
#: web/template/contacts/index.gohtml:23
msgctxt "title"
msgid "Email"
@ -199,25 +296,6 @@ msgctxt "action"
msgid "New product"
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
msgctxt "title"
msgid "Edit Product “%s”"
@ -254,39 +332,40 @@ msgstr "No podéis dejar la contraseña en blanco."
msgid "Invalid user or password."
msgstr "Nombre de usuario o contraseña inválido."
#: pkg/products.go:193
#: pkg/products.go:194 pkg/invoices.go:321
msgctxt "input"
msgid "Name"
msgstr "Nombre"
#: pkg/products.go:199
#: pkg/products.go:200 pkg/invoices.go:327
msgctxt "input"
msgid "Description"
msgstr "Descripción"
#: pkg/products.go:204
#: pkg/products.go:205 pkg/invoices.go:332
msgctxt "input"
msgid "Price"
msgstr "Precio"
#: pkg/products.go:214
#: pkg/products.go:215 pkg/invoices.go:237 pkg/invoices.go:361
msgctxt "input"
msgid "Taxes"
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."
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."
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."
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."
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."
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
msgctxt "input"
msgid "Business name"

View File

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

View File

@ -28,9 +28,23 @@
</tr>
</thead>
<tbody>
<tr>
<td colspan="7">{{( gettext "No invoices added yet." )}}</td>
</tr>
{{ 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>
<td colspan="7">{{( gettext "No invoices added yet." )}}</td>
</tr>
{{ end }}
</tbody>
</table>
{{- end }}

View File

@ -34,7 +34,10 @@
{{- end }}
<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>
</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 }}