From 993179674483f1ce5d521ea767b9eea487bc9429 Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Wed, 7 Jun 2023 16:35:31 +0200 Subject: [PATCH] Add HTTP controller and view to add quotes It still does not support quotes without contact or payment. --- pkg/invoices.go | 16 +- pkg/pgtypes.go | 119 +++ pkg/quote.go | 1102 +++++++++++++++++++++++ pkg/router.go | 9 + po/ca.po | 466 ++++++---- po/es.po | 470 ++++++---- web/static/numerus.css | 20 +- web/template/app.gohtml | 1 + web/template/form.gohtml | 23 + web/template/quotes/edit.gohtml | 103 +++ web/template/quotes/index.gohtml | 142 +++ web/template/quotes/new.gohtml | 101 +++ web/template/quotes/product-form.gohtml | 4 + web/template/quotes/products.gohtml | 76 ++ web/template/quotes/view.gohtml | 126 +++ 15 files changed, 2457 insertions(+), 321 deletions(-) create mode 100644 pkg/quote.go create mode 100644 web/template/quotes/edit.gohtml create mode 100644 web/template/quotes/index.gohtml create mode 100644 web/template/quotes/new.gohtml create mode 100644 web/template/quotes/product-form.gohtml create mode 100644 web/template/quotes/products.gohtml create mode 100644 web/template/quotes/view.gohtml diff --git a/pkg/invoices.go b/pkg/invoices.go index 07bf53a..ce7447c 100644 --- a/pkg/invoices.go +++ b/pkg/invoices.go @@ -604,8 +604,8 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co Name: "payment_method", Required: true, Label: pgettext("input", "Payment Method", locale), - Selected: []string{conn.MustGetText(ctx, "", "select default_payment_method_id::text from company where company_id = $1", company.Id)}, - Options: MustGetOptions(ctx, conn, "select payment_method_id::text, name from payment_method where company_id = $1", company.Id), + Selected: []string{mustGetDefaultPaymentMethod(ctx, conn, company)}, + Options: mustGetPaymentMethodOptions(ctx, conn, company), }, } } @@ -782,6 +782,14 @@ func mustGetContactOptions(ctx context.Context, conn *Conn, company *Company) [] return MustGetOptions(ctx, conn, "select contact_id::text, business_name from contact where company_id = $1 order by business_name", company.Id) } +func mustGetDefaultPaymentMethod(ctx context.Context, conn *Conn, company *Company) string { + return conn.MustGetText(ctx, "", "select default_payment_method_id::text from company where company_id = $1", company.Id) +} + +func mustGetPaymentMethodOptions(ctx context.Context, conn *Conn, company *Company) []*SelectOption { + return MustGetOptions(ctx, conn, "select payment_method_id::text, name from payment_method where company_id = $1", company.Id) +} + type invoiceProductForm struct { locale *Locale company *Company @@ -1044,9 +1052,9 @@ func HandleEditInvoiceAction(w http.ResponseWriter, r *http.Request, params http }) } -type renderFormFunc func(w http.ResponseWriter, r *http.Request, form *invoiceForm) +type renderInvoiceFormFunc func(w http.ResponseWriter, r *http.Request, form *invoiceForm) -func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderFormFunc) { +func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderInvoiceFormFunc) { locale := getLocale(r) conn := getConn(r) company := mustGetCompany(r) diff --git a/pkg/pgtypes.go b/pkg/pgtypes.go index ab77a34..422e948 100644 --- a/pkg/pgtypes.go +++ b/pkg/pgtypes.go @@ -66,6 +66,65 @@ func (src EditedInvoiceProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byt return array.EncodeBinary(ci, buf) } +type NewQuoteProductArray []*quoteProductForm + +func (src NewQuoteProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) { + typeName := "new_quote_product[]" + dt, ok := ci.DataTypeForName(typeName) + if !ok { + return nil, fmt.Errorf("unable to find oid for type name %v", typeName) + } + var values [][]interface{} + for _, form := range src { + var productId interface{} = form.ProductId.Val + if form.ProductId.Val == "" { + productId = nil + } + values = append(values, []interface{}{ + productId, + form.Name.Val, + form.Description.Val, + form.Price.Val, + form.Quantity.Val, + form.Discount.Float64() / 100.0, + form.Tax.Selected, + }) + } + array := pgtype.NewValue(dt.Value).(pgtype.ValueTranscoder) + if err := array.Set(values); err != nil { + return nil, err + } + return array.EncodeBinary(ci, buf) +} + +type EditedQuoteProductArray []*quoteProductForm + +func (src EditedQuoteProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) { + typeName := "edited_quote_product[]" + dt, ok := ci.DataTypeForName(typeName) + if !ok { + return nil, fmt.Errorf("unable to find oid for type name %v", typeName) + } + var values [][]interface{} + for _, form := range src { + values = append(values, []interface{}{ + form.QuoteProductId.IntegerOrNil(), + form.ProductId.IntegerOrNil(), + form.Name.Val, + form.Description.Val, + form.Price.Val, + form.Quantity.Val, + form.Discount.Float64() / 100.0, + form.Tax.Selected, + }) + } + array := pgtype.NewValue(dt.Value).(pgtype.ValueTranscoder) + if err := array.Set(values); err != nil { + return nil, err + } + return array.EncodeBinary(ci, buf) +} + func registerPgTypes(ctx context.Context, conn *pgx.Conn) error { if _, err := conn.Exec(ctx, "set role to admin"); err != nil { return err @@ -84,6 +143,7 @@ func registerPgTypes(ctx context.Context, conn *pgx.Conn) error { if err != nil { return err } + newInvoiceProduct, err := pgtype.NewCompositeType( "new_invoice_product", []pgtype.CompositeTypeField{ @@ -143,6 +203,65 @@ func registerPgTypes(ctx context.Context, conn *pgx.Conn) error { return err } + newQuoteProduct, err := pgtype.NewCompositeType( + "new_quote_product", + []pgtype.CompositeTypeField{ + {"product_id", pgtype.Int4OID}, + {"name", pgtype.TextOID}, + {"description", pgtype.TextOID}, + {"price", pgtype.TextOID}, + {"quantity", pgtype.Int4OID}, + {"discount_rate", discountRateOID}, + {"tax", pgtype.Int4ArrayOID}, + }, + conn.ConnInfo(), + ) + if err != nil { + return err + } + newQuoteProductOID, err := registerPgType(ctx, conn, newQuoteProduct, newQuoteProduct.TypeName()) + if err != nil { + return err + } + newQuoteProductArray := pgtype.NewArrayType("new_quote_product[]", newQuoteProductOID, func() pgtype.ValueTranscoder { + value := newQuoteProduct.NewTypeValue() + return value.(pgtype.ValueTranscoder) + }) + _, err = registerPgType(ctx, conn, newQuoteProductArray, newQuoteProductArray.TypeName()) + if err != nil { + return err + } + + editedQuoteProduct, err := pgtype.NewCompositeType( + "edited_quote_product", + []pgtype.CompositeTypeField{ + {"quote_product_id", pgtype.Int4OID}, + {"product_id", pgtype.Int4OID}, + {"name", pgtype.TextOID}, + {"description", pgtype.TextOID}, + {"price", pgtype.TextOID}, + {"quantity", pgtype.Int4OID}, + {"discount_rate", discountRateOID}, + {"tax", pgtype.Int4ArrayOID}, + }, + conn.ConnInfo(), + ) + if err != nil { + return err + } + editedQuoteProductOID, err := registerPgType(ctx, conn, editedQuoteProduct, editedQuoteProduct.TypeName()) + if err != nil { + return err + } + editedQuoteProductArray := pgtype.NewArrayType("edited_quote_product[]", editedQuoteProductOID, func() pgtype.ValueTranscoder { + value := editedQuoteProduct.NewTypeValue() + return value.(pgtype.ValueTranscoder) + }) + _, err = registerPgType(ctx, conn, editedQuoteProductArray, editedQuoteProductArray.TypeName()) + if err != nil { + return err + } + _, err = conn.Exec(ctx, "reset role") return err } diff --git a/pkg/quote.go b/pkg/quote.go new file mode 100644 index 0000000..858d344 --- /dev/null +++ b/pkg/quote.go @@ -0,0 +1,1102 @@ +package pkg + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "fmt" + "github.com/julienschmidt/httprouter" + "html/template" + "io" + "log" + "math" + "net/http" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +type QuoteEntry struct { + Slug string + Date time.Time + Number string + Total string + CustomerName string + Tags []string + Status string + StatusLabel string +} + +type QuotesIndexPage struct { + Quotes []*QuoteEntry + Filters *quoteFilterForm + QuoteStatuses map[string]string +} + +func IndexQuotes(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + conn := getConn(r) + locale := getLocale(r) + company := mustGetCompany(r) + filters := newQuoteFilterForm(r.Context(), conn, locale, company) + if err := filters.Parse(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + page := &QuotesIndexPage{ + Quotes: mustCollectQuoteEntries(r.Context(), conn, company, locale, filters), + Filters: filters, + QuoteStatuses: mustCollectQuoteStatuses(r.Context(), conn, locale), + } + mustRenderMainTemplate(w, r, "quotes/index.gohtml", page) +} + +func mustCollectQuoteEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale, filters *quoteFilterForm) []*QuoteEntry { + args := []interface{}{locale.Language.String(), company.Id} + where := []string{"quote.company_id = $2"} + appendWhere := func(expression string, value interface{}) { + args = append(args, value) + where = append(where, fmt.Sprintf(expression, len(args))) + } + maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) { + if value != "" { + if conv == nil { + appendWhere(expression, value) + } else { + appendWhere(expression, conv(value)) + } + } + } + maybeAppendWhere("contact_id = $%d", filters.Customer.String(), func(v string) interface{} { + customerId, _ := strconv.Atoi(filters.Customer.Selected[0]) + return customerId + }) + maybeAppendWhere("quote.quote_status = $%d", filters.QuoteStatus.String(), nil) + maybeAppendWhere("quote_number = $%d", filters.QuoteNumber.String(), nil) + maybeAppendWhere("quote_date >= $%d", filters.FromDate.String(), nil) + maybeAppendWhere("quote_date <= $%d", filters.ToDate.String(), nil) + if len(filters.Tags.Tags) > 0 { + if filters.TagsCondition.Selected == "and" { + appendWhere("quote.tags @> $%d", filters.Tags) + } else { + appendWhere("quote.tags && $%d", filters.Tags) + } + } + rows := conn.MustQuery(ctx, fmt.Sprintf(` + select quote.slug + , quote_date + , quote_number + , contact.business_name + , quote.tags + , quote.quote_status + , isi18n.name + , to_price(total, decimal_digits) + from quote + left join quote_contact using (quote_id) + left join contact using (contact_id) + join quote_status_i18n isi18n on quote.quote_status = isi18n.quote_status and isi18n.lang_tag = $1 + join quote_amount using (quote_id) + join currency using (currency_code) + where (%s) + order by quote_date desc + , quote_number desc + `, strings.Join(where, ") AND (")), args...) + defer rows.Close() + + var entries []*QuoteEntry + for rows.Next() { + entry := &QuoteEntry{} + if err := rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil { + panic(err) + } + entries = append(entries, entry) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return entries +} + +func mustCollectQuoteStatuses(ctx context.Context, conn *Conn, locale *Locale) map[string]string { + rows := conn.MustQuery(ctx, "select quote_status.quote_status, isi18n.name from quote_status join quote_status_i18n isi18n using(quote_status) where isi18n.lang_tag = $1 order by quote_status", locale.Language.String()) + defer rows.Close() + + statuses := map[string]string{} + for rows.Next() { + var key, name string + if err := rows.Scan(&key, &name); err != nil { + panic(err) + } + statuses[key] = name + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return statuses +} + +type quoteFilterForm struct { + locale *Locale + company *Company + Customer *SelectField + QuoteStatus *SelectField + QuoteNumber *InputField + FromDate *InputField + ToDate *InputField + Tags *TagsField + TagsCondition *ToggleField +} + +func newQuoteFilterForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *quoteFilterForm { + return "eFilterForm{ + locale: locale, + company: company, + Customer: &SelectField{ + Name: "customer", + Label: pgettext("input", "Customer", locale), + EmptyLabel: gettext("All customers", locale), + Options: mustGetContactOptions(ctx, conn, company), + }, + QuoteStatus: &SelectField{ + Name: "quote_status", + Label: pgettext("input", "Quotation Status", locale), + EmptyLabel: gettext("All status", locale), + Options: MustGetOptions(ctx, conn, "select quote_status.quote_status, isi18n.name from quote_status join quote_status_i18n isi18n using(quote_status) where isi18n.lang_tag = $1 order by quote_status", locale.Language.String()), + }, + QuoteNumber: &InputField{ + Name: "number", + Label: pgettext("input", "Quotation Number", locale), + Type: "search", + }, + FromDate: &InputField{ + Name: "from_date", + Label: pgettext("input", "From Date", locale), + Type: "date", + }, + ToDate: &InputField{ + Name: "to_date", + Label: pgettext("input", "To Date", locale), + Type: "date", + }, + 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("Quotations must have all the specified labels.", locale), + }, + SecondOption: &ToggleOption{ + Value: "or", + Label: pgettext("tag condition", "Any", locale), + Description: gettext("Quotations must have at least one of the specified labels.", locale), + }, + }, + } +} + +func (form *quoteFilterForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + form.Customer.FillValue(r) + form.QuoteStatus.FillValue(r) + form.QuoteNumber.FillValue(r) + form.FromDate.FillValue(r) + form.ToDate.FillValue(r) + form.Tags.FillValue(r) + form.TagsCondition.FillValue(r) + return nil +} + +func ServeQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + conn := getConn(r) + company := mustGetCompany(r) + slug := params[0].Value + switch slug { + case "new": + locale := getLocale(r) + form := newQuoteForm(r.Context(), conn, locale, company) + if quoteToDuplicate := r.URL.Query().Get("duplicate"); quoteToDuplicate != "" { + form.MustFillFromDatabase(r.Context(), conn, quoteToDuplicate) + form.QuoteStatus.Selected = []string{"created"} + } + form.Date.Val = time.Now().Format("2006-01-02") + w.WriteHeader(http.StatusOK) + mustRenderNewQuoteForm(w, r, form) + case "product-form": + query := r.URL.Query() + index, _ := strconv.Atoi(query.Get("index")) + form := newQuoteProductForm(index, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company)) + productSlug := query.Get("slug") + if len(productSlug) > 0 { + if !form.MustFillFromDatabase(r.Context(), conn, productSlug) { + 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, "quotes/product-form.gohtml", form) + default: + pdf := false + if strings.HasSuffix(slug, ".pdf") { + pdf = true + slug = slug[:len(slug)-len(".pdf")] + } + quo := mustGetQuote(r.Context(), conn, company, slug) + if quo == nil { + http.NotFound(w, r) + return + } + if pdf { + w.Header().Set("Content-Type", "application/pdf") + mustWriteQuotePdf(w, r, quo) + } else { + mustRenderMainTemplate(w, r, "quotes/view.gohtml", quo) + } + } +} + +func mustWriteQuotePdf(w io.Writer, r *http.Request, quo *quote) { + cmd := exec.Command("weasyprint", "--format", "pdf", "--stylesheet", "web/static/invoice.css", "-", "-") + var stderr bytes.Buffer + cmd.Stderr = &stderr + stdin, err := cmd.StdinPipe() + if err != nil { + panic(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + panic(err) + } + defer func() { + err := stdout.Close() + if !errors.Is(err, os.ErrClosed) { + panic(err) + } + }() + if err = cmd.Start(); err != nil { + panic(err) + } + go func() { + defer mustClose(stdin) + mustRenderAppTemplate(stdin, r, "quotes/view.gohtml", quo) + }() + if _, err = io.Copy(w, stdout); err != nil { + panic(err) + } + if err := cmd.Wait(); err != nil { + log.Printf("ERR - %v\n", stderr.String()) + panic(err) + } +} + +type quote struct { + Number string + Slug string + Date time.Time + Quoter taxDetails + Quotee taxDetails + TermsAndConditions string + Notes string + PaymentInstructions string + Products []*quoteProduct + Subtotal string + Taxes [][]string + TaxClasses []string + HasDiscounts bool + Total string + LegalDisclaimer string +} + +type quoteProduct struct { + Name string + Description string + Price string + Discount int + Quantity int + Taxes map[string]int + Subtotal string + Total string +} + +func mustGetQuote(ctx context.Context, conn *Conn, company *Company, slug string) *quote { + quo := "e{ + Slug: slug, + } + var quoteId int + var decimalDigits int + if notFoundErrorOrPanic(conn.QueryRow(ctx, ` + select quote_id + , decimal_digits + , quote_number + , quote_date + , terms_and_conditions + , notes + , coalesce(instructions, '') + , coalesce(business_name, '') + , vatin + , phone + , coalesce(email, '') + , coalesce(address, '') + , coalesce(city, '') + , coalesce(province, '') + , coalesce(postal_code, '') + , to_price(subtotal, decimal_digits) + , to_price(total, decimal_digits) + from quote + left join quote_payment_method using (quote_id) + left join payment_method using (payment_method_id) + left join quote_contact using (quote_id) + left join contact using (contact_id) + join quote_amount using (quote_id) + join currency using (currency_code) + where quote.slug = $1`, slug).Scan( + "eId, + &decimalDigits, + &quo.Number, + &quo.Date, + &quo.TermsAndConditions, + &quo.Notes, + &quo.PaymentInstructions, + &quo.Quotee.Name, + &quo.Quotee.VATIN, + &quo.Quotee.Phone, + &quo.Quotee.Email, + &quo.Quotee.Address, + &quo.Quotee.City, + &quo.Quotee.Province, + &quo.Quotee.PostalCode, + &quo.Subtotal, + &quo.Total)) { + return nil + } + if err := conn.QueryRow(ctx, "select business_name, vatin, phone, email, address, city, province, postal_code, legal_disclaimer from company where company_id = $1", company.Id).Scan(&quo.Quoter.Name, &quo.Quoter.VATIN, &quo.Quoter.Phone, &quo.Quoter.Email, &quo.Quoter.Address, &quo.Quoter.City, &quo.Quoter.Province, &quo.Quoter.PostalCode, &quo.LegalDisclaimer); err != nil { + panic(err) + } + if err := conn.QueryRow(ctx, "select array_agg(array[name, to_price(amount, $2)]) from quote_tax_amount join tax using (tax_id) where quote_id = $1", quoteId, decimalDigits).Scan(&quo.Taxes); err != nil { + panic(err) + } + rows := conn.MustQuery(ctx, "select quote_product.name, description, to_price(price, $2), (discount_rate * 100)::integer, quantity, to_price(subtotal, $2), to_price(total, $2), array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null) from quote_product join quote_product_amount using (quote_product_id) left join quote_product_tax using (quote_product_id) left join tax using (tax_id) left join tax_class using (tax_class_id) where quote_id = $1 group by quote_product.name, description, discount_rate, price, quantity, subtotal, total", quoteId, decimalDigits) + defer rows.Close() + taxClasses := map[string]bool{} + for rows.Next() { + product := "eProduct{ + Taxes: make(map[string]int), + } + var taxes [][]string + if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Discount, &product.Quantity, &product.Subtotal, &product.Total, &taxes); err != nil { + panic(err) + } + for _, tax := range taxes { + taxClass := tax[0] + taxClasses[taxClass] = true + product.Taxes[taxClass], _ = strconv.Atoi(tax[1]) + } + if product.Discount > 0 { + quo.HasDiscounts = true + } + quo.Products = append(quo.Products, product) + } + for taxClass := range taxClasses { + quo.TaxClasses = append(quo.TaxClasses, taxClass) + } + sort.Strings(quo.TaxClasses) + if rows.Err() != nil { + panic(rows.Err()) + } + + return quo +} + +type newQuotePage struct { + Form *quoteForm + Subtotal string + Taxes [][]string + Total string +} + +func newNewQuotePage(form *quoteForm, r *http.Request) *newQuotePage { + page := &newQuotePage{ + Form: form, + } + conn := getConn(r) + company := mustGetCompany(r) + err := conn.QueryRow(r.Context(), "select subtotal, taxes, total from compute_new_quote_amount($1, $2)", company.Id, NewQuoteProductArray(form.Products)).Scan(&page.Subtotal, &page.Taxes, &page.Total) + if err != nil { + panic(err) + } + if len(form.Products) == 0 { + form.Products = append(form.Products, newQuoteProductForm(0, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company))) + } + return page +} + +func mustRenderNewQuoteForm(w http.ResponseWriter, r *http.Request, form *quoteForm) { + page := newNewQuotePage(form, r) + mustRenderMainTemplate(w, r, "quotes/new.gohtml", page) +} + +func mustRenderNewQuoteProductsForm(w http.ResponseWriter, r *http.Request, action string, form *quoteForm) { + conn := getConn(r) + company := mustGetCompany(r) + page := newQuoteProductsPage{ + Action: companyURI(company, action), + Form: form, + Products: mustGetProductChoices(r.Context(), conn, company), + } + mustRenderMainTemplate(w, r, "quotes/products.gohtml", page) +} + +type newQuoteProductsPage struct { + Action string + Form *quoteForm + Products []*productChoice +} + +func HandleAddQuote(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + locale := getLocale(r) + conn := getConn(r) + company := mustGetCompany(r) + form := newQuoteForm(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 + } + if !form.Validate() { + if !IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + mustRenderNewQuoteForm(w, r, form) + return + } + slug := conn.MustGetText(r.Context(), "", "select add_quote($1, $2, $3, $4, $5, $6, $7, $8)", company.Id, form.Date, form.Customer, form.TermsAndConditions, form.Notes, form.PaymentMethod, form.Tags, NewQuoteProductArray(form.Products)) + htmxRedirect(w, r, companyURI(company, "/quotes/"+slug)) +} + +func HandleNewQuoteAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + switch params[0].Value { + case "new": + handleQuoteAction(w, r, "/quotes/new", mustRenderNewQuoteForm) + case "batch": + HandleBatchQuoteAction(w, r, params) + default: + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } +} + +func HandleBatchQuoteAction(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := verifyCsrfTokenValid(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + slugs := r.Form["quote"] + if len(slugs) == 0 { + http.Redirect(w, r, companyURI(mustGetCompany(r), "/quotes"), http.StatusSeeOther) + return + } + locale := getLocale(r) + switch r.Form.Get("action") { + case "download": + quotes := mustWriteQuotesPdf(r, slugs) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("quotations.zip", locale))) + w.WriteHeader(http.StatusOK) + if _, err := w.Write(quotes); err != nil { + panic(err) + } + default: + http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest) + } +} + +func mustWriteQuotesPdf(r *http.Request, slugs []string) []byte { + conn := getConn(r) + company := mustGetCompany(r) + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + for _, slug := range slugs { + quo := mustGetQuote(r.Context(), conn, company, slug) + if quo == nil { + continue + } + f, err := w.Create(quo.Number + ".pdf") + if err != nil { + panic(err) + } + mustWriteQuotePdf(f, r, quo) + } + mustClose(w) + return buf.Bytes() +} + +type quoteForm struct { + locale *Locale + company *Company + Number string + QuoteStatus *SelectField + Customer *SelectField + Date *InputField + TermsAndConditions *InputField + Notes *InputField + PaymentMethod *SelectField + Tags *TagsField + Products []*quoteProductForm + RemovedProduct *quoteProductForm +} + +func newQuoteForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *quoteForm { + return "eForm{ + locale: locale, + company: company, + QuoteStatus: &SelectField{ + Name: "quote_status", + Required: true, + Label: pgettext("input", "Quotation Status", locale), + Selected: []string{"created"}, + Options: MustGetOptions(ctx, conn, "select quote_status.quote_status, isi18n.name from quote_status join quote_status_i18n isi18n using(quote_status) where isi18n.lang_tag = $1 order by quote_status", locale.Language.String()), + }, + Customer: &SelectField{ + Name: "customer", + Label: pgettext("input", "Customer", locale), + EmptyLabel: gettext("Select a customer to quote.", locale), + Options: mustGetContactOptions(ctx, conn, company), + }, + Date: &InputField{ + Name: "date", + Label: pgettext("input", "Quotation Date", locale), + Type: "date", + Required: true, + }, + TermsAndConditions: &InputField{ + Name: "terms_and_conditions", + Label: pgettext("input", "Terms and conditions", locale), + Type: "textarea", + }, + Notes: &InputField{ + Name: "notes", + Label: pgettext("input", "Notes", locale), + Type: "textarea", + }, + Tags: &TagsField{ + Name: "tags", + Label: pgettext("input", "Tags", locale), + }, + PaymentMethod: &SelectField{ + Name: "payment_method", + Label: pgettext("input", "Payment Method", locale), + EmptyLabel: gettext("Select a payment method.", locale), + Options: mustGetPaymentMethodOptions(ctx, conn, company), + }, + } +} + +func (form *quoteForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + form.QuoteStatus.FillValue(r) + form.Customer.FillValue(r) + form.Date.FillValue(r) + form.Notes.FillValue(r) + form.Tags.FillValue(r) + form.PaymentMethod.FillValue(r) + if _, ok := r.Form["product.id.0"]; ok { + taxOptions := mustGetTaxOptions(r.Context(), getConn(r), form.company) + for index := 0; true; index++ { + if _, ok := r.Form["product.id."+strconv.Itoa(index)]; !ok { + break + } + productForm := newQuoteProductForm(index, form.company, form.locale, taxOptions) + if err := productForm.Parse(r); err != nil { + return err + } + form.Products = append(form.Products, productForm) + } + } + return nil +} + +func (form *quoteForm) Validate() bool { + validator := newFormValidator() + + validator.CheckValidSelectOption(form.QuoteStatus, gettext("Selected quotation status is not valid.", form.locale)) + if form.Customer.String() != "" { + validator.CheckValidSelectOption(form.Customer, gettext("Selected customer is not valid.", form.locale)) + } + if validator.CheckRequiredInput(form.Date, gettext("Quotation date can not be empty.", form.locale)) { + validator.CheckValidDate(form.Date, gettext("Quotation date must be a valid date.", form.locale)) + } + if form.PaymentMethod.String() != "" { + validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale)) + } + + allOK := validator.AllOK() + for _, product := range form.Products { + allOK = product.Validate() && allOK + } + return allOK +} + +func (form *quoteForm) Update() { + products := form.Products + form.Products = nil + for n, product := range products { + if product.Quantity.Val != "0" { + product.Update() + if n != len(form.Products) { + product.Index = len(form.Products) + product.Rename() + } + form.Products = append(form.Products, product) + } + } +} + +func (form *quoteForm) RemoveProduct(index int) { + products := form.Products + form.Products = nil + for n, product := range products { + if n == index { + form.RemovedProduct = product + } else { + if n != len(form.Products) { + product.Index = len(form.Products) + product.Rename() + } + form.Products = append(form.Products, product) + } + } + if form.RemovedProduct != nil { + form.RemovedProduct.RenameWithSuffix(removedProductSuffix) + } +} + +func (form *quoteForm) AddProducts(ctx context.Context, conn *Conn, productsSlug []string) { + form.mustAddProductsFromQuery(ctx, conn, selectProductBySlug, productsSlug) +} + +func (form *quoteForm) mustAddProductsFromQuery(ctx context.Context, conn *Conn, sql string, args ...interface{}) { + index := len(form.Products) + taxOptions := mustGetTaxOptions(ctx, conn, form.company) + rows := conn.MustQuery(ctx, sql, args...) + defer rows.Close() + for rows.Next() { + product := newQuoteProductForm(index, form.company, form.locale, taxOptions) + if err := rows.Scan(product.QuoteProductId, 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()) + } +} + +func (form *quoteForm) InsertProduct(product *quoteProductForm) { + replaced := false + for n, existing := range form.Products { + if existing.Quantity.Val == "" || existing.Quantity.Val == "0" { + product.Index = n + form.Products[n] = product + replaced = true + break + } + } + if !replaced { + product.Index = len(form.Products) + form.Products = append(form.Products, product) + } + product.Rename() +} + +func (form *quoteForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { + var quoteId int + selectedQuoteStatus := form.QuoteStatus.Selected + form.QuoteStatus.Clear() + selectedPaymentMethod := form.PaymentMethod.Selected + form.PaymentMethod.Clear() + if notFoundErrorOrPanic(conn.QueryRow(ctx, ` + select quote_id + , quote_status + , contact_id + , quote_number + , quote_date + , notes + , payment_method_id + , tags + from quote + left join quote_contact using (quote_id) + left join quote_payment_method using (quote_id) + where slug = $1 + `, slug).Scan("eId, form.QuoteStatus, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) { + form.PaymentMethod.Selected = selectedPaymentMethod + form.QuoteStatus.Selected = selectedQuoteStatus + return false + } + form.Products = []*quoteProductForm{} + form.mustAddProductsFromQuery(ctx, conn, "select quote_product_id::text, coalesce(product_id, 0), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id, 0), name, description, discount_rate, price, quantity", quoteId, form.company.DecimalDigits) + return true +} + +type quoteProductForm struct { + locale *Locale + company *Company + Index int + QuoteProductId *InputField + ProductId *InputField + Name *InputField + Description *InputField + Price *InputField + Quantity *InputField + Discount *InputField + Tax *SelectField +} + +func newQuoteProductForm(index int, company *Company, locale *Locale, taxOptions []*SelectOption) *quoteProductForm { + triggerRecompute := template.HTMLAttr(`data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', {bubbles: true}))"`) + form := "eProductForm{ + locale: locale, + company: company, + Index: index, + QuoteProductId: &InputField{ + Label: pgettext("input", "Id", locale), + Type: "hidden", + Required: true, + }, + ProductId: &InputField{ + Label: pgettext("input", "Id", locale), + Type: "hidden", + Required: true, + }, + Name: &InputField{ + 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), + Type: "textarea", + }, + Price: &InputField{ + Label: pgettext("input", "Price", locale), + Type: "number", + Required: true, + Attributes: []template.HTMLAttr{ + triggerRecompute, + `min="0"`, + template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())), + }, + }, + Quantity: &InputField{ + Label: pgettext("input", "Quantity", locale), + Type: "number", + Required: true, + Attributes: []template.HTMLAttr{ + triggerRecompute, + `min="0"`, + }, + }, + Discount: &InputField{ + Label: pgettext("input", "Discount (%)", locale), + Type: "number", + Required: true, + Attributes: []template.HTMLAttr{ + triggerRecompute, + `min="0"`, + `max="100"`, + }, + }, + Tax: &SelectField{ + Label: pgettext("input", "Taxes", locale), + Multiple: true, + Options: taxOptions, + Attributes: []template.HTMLAttr{ + triggerRecompute, + }, + }, + } + form.Rename() + return form +} + +func (form *quoteProductForm) Rename() { + form.RenameWithSuffix("." + strconv.Itoa(form.Index)) +} +func (form *quoteProductForm) RenameWithSuffix(suffix string) { + form.QuoteProductId.Name = "product.quote_product_id" + suffix + form.ProductId.Name = "product.id" + suffix + form.Name.Name = "product.name" + suffix + form.Description.Name = "product.description" + suffix + form.Price.Name = "product.price" + suffix + form.Quantity.Name = "product.quantity" + suffix + form.Discount.Name = "product.discount" + suffix + form.Tax.Name = "product.tax" + suffix +} + +func (form *quoteProductForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + form.QuoteProductId.FillValue(r) + 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 *quoteProductForm) Validate() bool { + validator := newFormValidator() + if form.QuoteProductId.Val != "" { + validator.CheckValidInteger(form.QuoteProductId, 1, math.MaxInt32, gettext("Quotation product ID must be a number greater than zero.", form.locale)) + } + if form.ProductId.Val != "" { + validator.CheckValidInteger(form.ProductId, 0, math.MaxInt32, gettext("Product ID must be a positive number or zero.", form.locale)) + } + 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.MaxInt32, 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)) + validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale)) + return validator.AllOK() +} + +func (form *quoteProductForm) Update() { + validator := newFormValidator() + if !validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, "") { + form.Price.Val = "0.0" + form.Price.Errors = nil + } + if !validator.CheckValidInteger(form.Quantity, 0, math.MaxInt32, "") { + form.Quantity.Val = "1" + form.Quantity.Errors = nil + } + if !validator.CheckValidInteger(form.Discount, 0, 100, "") { + form.Discount.Val = "0" + form.Discount.Errors = nil + } +} + +func (form *quoteProductForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { + return !notFoundErrorOrPanic(conn.QueryRow(ctx, selectProductBySlug, []string{slug}).Scan( + form.QuoteProductId, + form.ProductId, + form.Name, + form.Description, + form.Price, + form.Quantity, + form.Discount, + form.Tax)) +} + +func HandleUpdateQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + locale := getLocale(r) + conn := getConn(r) + company := mustGetCompany(r) + form := newQuoteForm(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 + } + if r.FormValue("quick") == "status" { + slug := conn.MustGetText(r.Context(), "", "update quote set quote_status = $1 where slug = $2 returning slug", form.QuoteStatus, params[0].Value) + if slug == "" { + http.NotFound(w, r) + } + htmxRedirect(w, r, companyURI(mustGetCompany(r), "/quotes")) + } else { + slug := params[0].Value + if !form.Validate() { + if !IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + mustRenderEditQuoteForm(w, r, slug, form) + return + } + slug = conn.MustGetText(r.Context(), "", "select edit_quote($1, $2, $3, $4, $5, $6, $7, $8)", slug, form.QuoteStatus, form.Customer, form.TermsAndConditions, form.Notes, form.PaymentMethod, form.Tags, EditedQuoteProductArray(form.Products)) + if slug == "" { + http.NotFound(w, r) + return + } + htmxRedirect(w, r, companyURI(company, "/quotes/"+slug)) + } +} + +func ServeEditQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + conn := getConn(r) + company := mustGetCompany(r) + slug := params[0].Value + locale := getLocale(r) + form := newQuoteForm(r.Context(), conn, locale, company) + if !form.MustFillFromDatabase(r.Context(), conn, slug) { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + mustRenderEditQuoteForm(w, r, slug, form) +} + +type editQuotePage struct { + *newQuotePage + Slug string + Number string +} + +func newEditQuotePage(slug string, form *quoteForm, r *http.Request) *editQuotePage { + return &editQuotePage{ + newNewQuotePage(form, r), + slug, + form.Number, + } +} + +func mustRenderEditQuoteForm(w http.ResponseWriter, r *http.Request, slug string, form *quoteForm) { + page := newEditQuotePage(slug, form, r) + mustRenderMainTemplate(w, r, "quotes/edit.gohtml", page) +} + +func HandleEditQuoteAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + slug := params[0].Value + actionUri := fmt.Sprintf("/quotes/%s/edit", slug) + handleQuoteAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *quoteForm) { + conn := getConn(r) + form.Number = conn.MustGetText(r.Context(), "", "select quote_number from quote where slug = $1", slug) + mustRenderEditQuoteForm(w, r, slug, form) + }) +} + +type renderQuoteFormFunc func(w http.ResponseWriter, r *http.Request, form *quoteForm) + +func handleQuoteAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderQuoteFormFunc) { + locale := getLocale(r) + conn := getConn(r) + company := mustGetCompany(r) + form := newQuoteForm(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 + } + actionField := r.Form.Get("action") + switch actionField { + case "update": + form.Update() + w.WriteHeader(http.StatusOK) + renderForm(w, r, form) + case "select-products": + w.WriteHeader(http.StatusOK) + mustRenderNewQuoteProductsForm(w, r, action, form) + case "add-products": + form.AddProducts(r.Context(), conn, r.Form["slug"]) + w.WriteHeader(http.StatusOK) + renderForm(w, r, form) + case "restore-product": + restoredProduct := newQuoteProductForm(0, company, locale, mustGetTaxOptions(r.Context(), conn, company)) + restoredProduct.RenameWithSuffix(removedProductSuffix) + if err := restoredProduct.Parse(r); err != nil { + panic(err) + } + form.InsertProduct(restoredProduct) + form.Update() + w.WriteHeader(http.StatusOK) + renderForm(w, r, form) + default: + prefix := "remove-product." + if strings.HasPrefix(actionField, prefix) { + index, err := strconv.Atoi(actionField[len(prefix):]) + if err != nil { + http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest) + } else { + form.RemoveProduct(index) + form.Update() + w.WriteHeader(http.StatusOK) + renderForm(w, r, form) + } + } else { + http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest) + } + } +} + +func ServeEditQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + conn := getConn(r) + locale := getLocale(r) + company := getCompany(r) + slug := params[0].Value + form := newTagsForm(companyURI(company, "/quotes/"+slug+"/tags"), slug, locale) + if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from quote where slug = $1`, form.Slug).Scan(form.Tags)) { + http.NotFound(w, r) + return + } + mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form) +} + +func HandleUpdateQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + locale := getLocale(r) + conn := getConn(r) + company := getCompany(r) + slug := params[0].Value + form := newTagsForm(companyURI(company, "/quotes/"+slug+"/tags/edit"), slug, locale) + 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 + } + if conn.MustGetText(r.Context(), "", "update quote set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" { + http.NotFound(w, r) + } + mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form) +} diff --git a/pkg/router.go b/pkg/router.go index d2f49ad..3cbc165 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -37,6 +37,15 @@ 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("/quotes", IndexQuotes) + companyRouter.POST("/quotes", HandleAddQuote) + companyRouter.GET("/quotes/:slug", ServeQuote) + companyRouter.PUT("/quotes/:slug", HandleUpdateQuote) + companyRouter.POST("/quotes/:slug", HandleNewQuoteAction) + companyRouter.GET("/quotes/:slug/edit", ServeEditQuote) + companyRouter.POST("/quotes/:slug/edit", HandleEditQuoteAction) + companyRouter.PUT("/quotes/:slug/tags", HandleUpdateQuoteTags) + companyRouter.GET("/quotes/:slug/tags/edit", ServeEditQuoteTags) companyRouter.GET("/search/products", HandleProductSearch) companyRouter.GET("/expenses", IndexExpenses) companyRouter.POST("/expenses", HandleAddExpense) diff --git a/po/ca.po b/po/ca.po index ab1d8cf..c2169b9 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-05-29 00:02+0200\n" +"POT-Creation-Date: 2023-06-07 16:05+0200\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -25,12 +25,15 @@ msgstr "Afegeix productes a la factura" #: web/template/invoices/products.gohtml:9 web/template/invoices/new.gohtml:9 #: web/template/invoices/index.gohtml:9 web/template/invoices/view.gohtml:9 -#: web/template/invoices/edit.gohtml:9 web/template/contacts/new.gohtml:9 -#: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10 -#: web/template/profile.gohtml:9 web/template/expenses/new.gohtml:10 -#: web/template/expenses/index.gohtml:10 web/template/expenses/edit.gohtml:10 -#: web/template/tax-details.gohtml:9 web/template/products/new.gohtml:9 -#: web/template/products/index.gohtml:9 web/template/products/edit.gohtml:10 +#: web/template/invoices/edit.gohtml:9 web/template/quotes/products.gohtml:9 +#: web/template/quotes/new.gohtml:9 web/template/quotes/index.gohtml:9 +#: web/template/quotes/view.gohtml:9 web/template/quotes/edit.gohtml:9 +#: web/template/contacts/new.gohtml:9 web/template/contacts/index.gohtml:9 +#: web/template/contacts/edit.gohtml:10 web/template/profile.gohtml:9 +#: web/template/expenses/new.gohtml:10 web/template/expenses/index.gohtml:10 +#: web/template/expenses/edit.gohtml:10 web/template/tax-details.gohtml:9 +#: web/template/products/new.gohtml:9 web/template/products/index.gohtml:9 +#: web/template/products/edit.gohtml:10 msgctxt "title" msgid "Home" msgstr "Inici" @@ -49,60 +52,70 @@ msgid "New Invoice" msgstr "Nova factura" #: web/template/invoices/products.gohtml:48 +#: web/template/quotes/products.gohtml:48 msgctxt "product" msgid "All" msgstr "Tots" #: web/template/invoices/products.gohtml:49 -#: web/template/products/index.gohtml:40 +#: web/template/quotes/products.gohtml:49 web/template/products/index.gohtml:40 msgctxt "title" msgid "Name" msgstr "Nom" #: web/template/invoices/products.gohtml:50 -#: web/template/invoices/view.gohtml:62 web/template/products/index.gohtml:42 +#: web/template/invoices/view.gohtml:62 web/template/quotes/products.gohtml:50 +#: web/template/quotes/view.gohtml:62 web/template/products/index.gohtml:42 msgctxt "title" msgid "Price" msgstr "Preu" #: web/template/invoices/products.gohtml:64 -#: web/template/products/index.gohtml:82 +#: web/template/quotes/products.gohtml:64 web/template/products/index.gohtml:82 msgid "No products added yet." msgstr "No hi ha cap producte." #: web/template/invoices/products.gohtml:72 web/template/invoices/new.gohtml:83 -#: web/template/invoices/edit.gohtml:84 +#: web/template/invoices/edit.gohtml:84 web/template/quotes/products.gohtml:72 +#: web/template/quotes/new.gohtml:84 web/template/quotes/edit.gohtml:85 msgctxt "action" msgid "Add products" msgstr "Afegeix productes" #: web/template/invoices/new.gohtml:27 web/template/invoices/edit.gohtml:27 +#: web/template/quotes/new.gohtml:27 web/template/quotes/edit.gohtml:27 msgid "Product “%s” removed" msgstr "S’ha esborrat el producte «%s»" #: web/template/invoices/new.gohtml:31 web/template/invoices/edit.gohtml:31 +#: web/template/quotes/new.gohtml:31 web/template/quotes/edit.gohtml:31 msgctxt "action" msgid "Undo" msgstr "Desfes" #: web/template/invoices/new.gohtml:60 web/template/invoices/view.gohtml:67 -#: web/template/invoices/edit.gohtml:61 +#: web/template/invoices/edit.gohtml:61 web/template/quotes/new.gohtml:61 +#: web/template/quotes/view.gohtml:67 web/template/quotes/edit.gohtml:62 msgctxt "title" msgid "Subtotal" msgstr "Subtotal" #: web/template/invoices/new.gohtml:70 web/template/invoices/view.gohtml:71 #: web/template/invoices/view.gohtml:111 web/template/invoices/edit.gohtml:71 +#: web/template/quotes/new.gohtml:71 web/template/quotes/view.gohtml:71 +#: web/template/quotes/view.gohtml:111 web/template/quotes/edit.gohtml:72 msgctxt "title" msgid "Total" msgstr "Total" #: web/template/invoices/new.gohtml:87 web/template/invoices/edit.gohtml:88 +#: web/template/quotes/new.gohtml:88 web/template/quotes/edit.gohtml:89 msgctxt "action" msgid "Update" msgstr "Actualitza" #: web/template/invoices/new.gohtml:90 web/template/invoices/edit.gohtml:91 +#: web/template/quotes/new.gohtml:91 web/template/quotes/edit.gohtml:92 #: web/template/contacts/new.gohtml:39 web/template/contacts/edit.gohtml:43 #: web/template/expenses/new.gohtml:33 web/template/expenses/edit.gohtml:38 #: web/template/products/new.gohtml:30 web/template/products/edit.gohtml:36 @@ -121,8 +134,8 @@ msgid "New invoice" msgstr "Nova factura" #: web/template/invoices/index.gohtml:43 web/template/dashboard.gohtml:23 -#: web/template/contacts/index.gohtml:34 web/template/expenses/index.gohtml:36 -#: web/template/products/index.gohtml:34 +#: web/template/quotes/index.gohtml:43 web/template/contacts/index.gohtml:34 +#: web/template/expenses/index.gohtml:36 web/template/products/index.gohtml:34 msgctxt "action" msgid "Filter" msgstr "Filtra" @@ -133,6 +146,7 @@ msgid "All" msgstr "Totes" #: web/template/invoices/index.gohtml:50 web/template/invoices/view.gohtml:34 +#: web/template/quotes/index.gohtml:50 web/template/quotes/view.gohtml:34 msgctxt "title" msgid "Date" msgstr "Data" @@ -142,34 +156,39 @@ msgctxt "title" msgid "Invoice Num." msgstr "Núm. factura" -#: web/template/invoices/index.gohtml:52 web/template/contacts/index.gohtml:40 +#: web/template/invoices/index.gohtml:52 web/template/quotes/index.gohtml:52 +#: web/template/contacts/index.gohtml:40 msgctxt "title" msgid "Customer" msgstr "Client" -#: web/template/invoices/index.gohtml:53 +#: web/template/invoices/index.gohtml:53 web/template/quotes/index.gohtml:53 msgctxt "title" msgid "Status" msgstr "Estat" -#: web/template/invoices/index.gohtml:54 web/template/contacts/index.gohtml:43 -#: web/template/expenses/index.gohtml:46 web/template/products/index.gohtml:41 +#: web/template/invoices/index.gohtml:54 web/template/quotes/index.gohtml:54 +#: web/template/contacts/index.gohtml:43 web/template/expenses/index.gohtml:46 +#: web/template/products/index.gohtml:41 msgctxt "title" msgid "Tags" msgstr "Etiquetes" -#: web/template/invoices/index.gohtml:55 web/template/expenses/index.gohtml:47 +#: web/template/invoices/index.gohtml:55 web/template/quotes/index.gohtml:55 +#: web/template/expenses/index.gohtml:47 msgctxt "title" msgid "Amount" msgstr "Import" -#: web/template/invoices/index.gohtml:56 web/template/expenses/index.gohtml:48 +#: web/template/invoices/index.gohtml:56 web/template/quotes/index.gohtml:56 +#: web/template/expenses/index.gohtml:48 msgctxt "title" msgid "Download" msgstr "Descàrrega" -#: web/template/invoices/index.gohtml:57 web/template/contacts/index.gohtml:44 -#: web/template/expenses/index.gohtml:49 web/template/products/index.gohtml:43 +#: web/template/invoices/index.gohtml:57 web/template/quotes/index.gohtml:57 +#: web/template/contacts/index.gohtml:44 web/template/expenses/index.gohtml:49 +#: web/template/products/index.gohtml:43 msgctxt "title" msgid "Actions" msgstr "Accions" @@ -180,6 +199,7 @@ msgid "Select invoice %v" msgstr "Selecciona factura %v" #: web/template/invoices/index.gohtml:119 web/template/invoices/view.gohtml:19 +#: web/template/quotes/index.gohtml:119 web/template/quotes/view.gohtml:19 #: web/template/contacts/index.gohtml:74 web/template/expenses/index.gohtml:88 #: web/template/products/index.gohtml:72 msgctxt "action" @@ -187,6 +207,7 @@ msgid "Edit" msgstr "Edita" #: web/template/invoices/index.gohtml:127 web/template/invoices/view.gohtml:16 +#: web/template/quotes/index.gohtml:127 web/template/quotes/view.gohtml:16 msgctxt "action" msgid "Duplicate" msgstr "Duplica" @@ -205,22 +226,22 @@ msgctxt "action" msgid "Download invoice" msgstr "Descarrega factura" -#: web/template/invoices/view.gohtml:61 +#: web/template/invoices/view.gohtml:61 web/template/quotes/view.gohtml:61 msgctxt "title" msgid "Concept" msgstr "Concepte" -#: web/template/invoices/view.gohtml:64 +#: web/template/invoices/view.gohtml:64 web/template/quotes/view.gohtml:64 msgctxt "title" msgid "Discount" msgstr "Descompte" -#: web/template/invoices/view.gohtml:66 +#: web/template/invoices/view.gohtml:66 web/template/quotes/view.gohtml:66 msgctxt "title" msgid "Units" msgstr "Unitats" -#: web/template/invoices/view.gohtml:101 +#: web/template/invoices/view.gohtml:101 web/template/quotes/view.gohtml:101 msgctxt "title" msgid "Tax Base" msgstr "Base imposable" @@ -235,7 +256,7 @@ msgctxt "input" msgid "(Max. %s)" msgstr "(Màx. %s)" -#: web/template/form.gohtml:171 +#: web/template/form.gohtml:194 msgctxt "action" msgid "Filters" msgstr "Filtra" @@ -275,6 +296,68 @@ msgctxt "term" msgid "Net Income" msgstr "Ingressos nets" +#: web/template/quotes/products.gohtml:2 web/template/quotes/products.gohtml:23 +msgctxt "title" +msgid "Add Products to Quotation" +msgstr "Afegeix productes al pressupost" + +#: web/template/quotes/products.gohtml:10 web/template/quotes/new.gohtml:10 +#: web/template/quotes/index.gohtml:2 web/template/quotes/index.gohtml:10 +#: web/template/quotes/view.gohtml:10 web/template/quotes/edit.gohtml:10 +msgctxt "title" +msgid "Quotations" +msgstr "Pressuposts" + +#: web/template/quotes/products.gohtml:12 web/template/quotes/new.gohtml:2 +#: web/template/quotes/new.gohtml:11 web/template/quotes/new.gohtml:19 +msgctxt "title" +msgid "New Quotation" +msgstr "Nou pressupost" + +#: web/template/quotes/index.gohtml:19 +msgctxt "action" +msgid "Download quotations" +msgstr "Descarrega pressuposts" + +#: web/template/quotes/index.gohtml:21 +msgctxt "action" +msgid "New quotation" +msgstr "Nou pressupost" + +#: web/template/quotes/index.gohtml:49 +msgctxt "quote" +msgid "All" +msgstr "Tots" + +#: web/template/quotes/index.gohtml:51 +msgctxt "title" +msgid "Quotation Num." +msgstr "Núm. pressupost" + +#: web/template/quotes/index.gohtml:64 +msgctxt "action" +msgid "Select quotation %v" +msgstr "Selecciona pressupost %v" + +#: web/template/quotes/index.gohtml:137 +msgid "No quotations added yet." +msgstr "No hi ha cap pressupost." + +#: web/template/quotes/view.gohtml:2 web/template/quotes/view.gohtml:33 +msgctxt "title" +msgid "Quotation %s" +msgstr "Pressupost %s" + +#: web/template/quotes/view.gohtml:22 +msgctxt "action" +msgid "Download quotation" +msgstr "Descarrega pressupost" + +#: web/template/quotes/edit.gohtml:2 web/template/quotes/edit.gohtml:19 +msgctxt "title" +msgid "Edit Quotation “%s”" +msgstr "Edició del pressupost «%s»" + #: web/template/app.gohtml:23 msgctxt "menu" msgid "Account" @@ -297,20 +380,25 @@ msgstr "Tauler" #: web/template/app.gohtml:47 msgctxt "nav" +msgid "Quotations" +msgstr "Pressuposts" + +#: web/template/app.gohtml:48 +msgctxt "nav" msgid "Invoices" msgstr "Factures" -#: web/template/app.gohtml:48 +#: web/template/app.gohtml:49 msgctxt "nav" msgid "Expenses" msgstr "Despeses" -#: web/template/app.gohtml:49 +#: web/template/app.gohtml:50 msgctxt "nav" msgid "Products" msgstr "Productes" -#: web/template/app.gohtml:50 +#: web/template/app.gohtml:51 msgctxt "nav" msgid "Contacts" msgstr "Contactes" @@ -382,7 +470,7 @@ msgctxt "title" msgid "Language" msgstr "Idioma" -#: web/template/profile.gohtml:39 web/template/tax-details.gohtml:172 +#: web/template/profile.gohtml:39 web/template/tax-details.gohtml:173 msgctxt "action" msgid "Save changes" msgstr "Desa canvis" @@ -449,54 +537,54 @@ msgctxt "title" msgid "Invoicing" msgstr "Facturació" -#: web/template/tax-details.gohtml:53 +#: web/template/tax-details.gohtml:54 msgid "Are you sure?" msgstr "N’esteu segur?" -#: web/template/tax-details.gohtml:59 +#: web/template/tax-details.gohtml:60 msgctxt "title" msgid "Tax Name" msgstr "Nom impost" -#: web/template/tax-details.gohtml:60 +#: web/template/tax-details.gohtml:61 msgctxt "title" msgid "Rate (%)" msgstr "Percentatge" -#: web/template/tax-details.gohtml:61 +#: web/template/tax-details.gohtml:62 msgctxt "title" msgid "Class" msgstr "Classe" -#: web/template/tax-details.gohtml:85 +#: web/template/tax-details.gohtml:86 msgid "No taxes added yet." msgstr "No hi ha cap impost." -#: web/template/tax-details.gohtml:91 web/template/tax-details.gohtml:152 +#: web/template/tax-details.gohtml:92 web/template/tax-details.gohtml:153 msgctxt "title" msgid "New Line" msgstr "Nova línia" -#: web/template/tax-details.gohtml:105 +#: web/template/tax-details.gohtml:106 msgctxt "action" msgid "Add new tax" msgstr "Afegeix nou impost" -#: web/template/tax-details.gohtml:121 +#: web/template/tax-details.gohtml:122 msgctxt "title" msgid "Payment Method" msgstr "Mètode de pagament" -#: web/template/tax-details.gohtml:122 +#: web/template/tax-details.gohtml:123 msgctxt "title" msgid "Instructions" msgstr "Instruccions" -#: web/template/tax-details.gohtml:146 +#: web/template/tax-details.gohtml:147 msgid "No payment methods added yet." msgstr "No hi ha cap mètode de pagament." -#: web/template/tax-details.gohtml:164 +#: web/template/tax-details.gohtml:165 msgctxt "action" msgid "Add new payment method" msgstr "Afegeix nou mètode de pagament" @@ -553,26 +641,27 @@ msgstr "No podeu deixar la contrasenya en blanc." msgid "Invalid user or password." msgstr "Nom d’usuari o contrasenya incorrectes." -#: pkg/products.go:164 pkg/products.go:263 pkg/invoices.go:816 +#: pkg/products.go:164 pkg/products.go:263 pkg/quote.go:793 pkg/invoices.go:824 #: pkg/contacts.go:135 msgctxt "input" msgid "Name" msgstr "Nom" -#: pkg/products.go:169 pkg/products.go:290 pkg/expenses.go:202 -#: pkg/expenses.go:361 pkg/invoices.go:189 pkg/invoices.go:601 -#: pkg/invoices.go:1115 pkg/contacts.go:140 pkg/contacts.go:325 +#: pkg/products.go:169 pkg/products.go:290 pkg/quote.go:188 pkg/quote.go:606 +#: pkg/expenses.go:202 pkg/expenses.go:361 pkg/invoices.go:189 +#: pkg/invoices.go:601 pkg/invoices.go:1123 pkg/contacts.go:140 +#: pkg/contacts.go:325 msgctxt "input" msgid "Tags" msgstr "Etiquetes" -#: pkg/products.go:173 pkg/expenses.go:365 pkg/invoices.go:193 +#: pkg/products.go:173 pkg/quote.go:192 pkg/expenses.go:365 pkg/invoices.go:193 #: pkg/contacts.go:144 msgctxt "input" msgid "Tags Condition" msgstr "Condició de les etiquetes" -#: pkg/products.go:177 pkg/expenses.go:369 pkg/invoices.go:197 +#: pkg/products.go:177 pkg/quote.go:196 pkg/expenses.go:369 pkg/invoices.go:197 #: pkg/contacts.go:148 msgctxt "tag condition" msgid "All" @@ -583,7 +672,7 @@ msgstr "Totes" msgid "Invoices must have all the specified labels." msgstr "Les factures han de tenir totes les etiquetes." -#: pkg/products.go:182 pkg/expenses.go:374 pkg/invoices.go:202 +#: pkg/products.go:182 pkg/quote.go:201 pkg/expenses.go:374 pkg/invoices.go:202 #: pkg/contacts.go:153 msgctxt "tag condition" msgid "Any" @@ -594,119 +683,262 @@ msgstr "Qualsevol" msgid "Invoices must have at least one of the specified labels." msgstr "Les factures han de tenir com a mínim una de les etiquetes." -#: pkg/products.go:269 pkg/invoices.go:830 +#: pkg/products.go:269 pkg/quote.go:807 pkg/invoices.go:838 msgctxt "input" msgid "Description" msgstr "Descripció" -#: pkg/products.go:274 pkg/invoices.go:834 +#: pkg/products.go:274 pkg/quote.go:811 pkg/invoices.go:842 msgctxt "input" msgid "Price" msgstr "Preu" -#: pkg/products.go:284 pkg/expenses.go:181 pkg/invoices.go:863 +#: pkg/products.go:284 pkg/quote.go:840 pkg/expenses.go:181 pkg/invoices.go:871 msgctxt "input" msgid "Taxes" msgstr "Imposts" -#: pkg/products.go:309 pkg/profile.go:92 pkg/invoices.go:912 +#: pkg/products.go:309 pkg/quote.go:889 pkg/profile.go:92 pkg/invoices.go:920 msgid "Name can not be empty." msgstr "No podeu deixar el nom en blanc." -#: pkg/products.go:310 pkg/invoices.go:913 +#: pkg/products.go:310 pkg/quote.go:890 pkg/invoices.go:921 msgid "Price can not be empty." msgstr "No podeu deixar el preu en blanc." -#: pkg/products.go:311 pkg/invoices.go:914 +#: pkg/products.go:311 pkg/quote.go:891 pkg/invoices.go:922 msgid "Price must be a number greater than zero." msgstr "El preu ha de ser un número major a zero." -#: pkg/products.go:313 pkg/expenses.go:227 pkg/expenses.go:232 -#: pkg/invoices.go:922 +#: pkg/products.go:313 pkg/quote.go:899 pkg/expenses.go:227 pkg/expenses.go:232 +#: pkg/invoices.go:930 msgid "Selected tax is not valid." msgstr "Heu seleccionat un impost que no és vàlid." -#: pkg/products.go:314 pkg/expenses.go:228 pkg/expenses.go:233 -#: pkg/invoices.go:923 +#: pkg/products.go:314 pkg/quote.go:900 pkg/expenses.go:228 pkg/expenses.go:233 +#: pkg/invoices.go:931 msgid "You can only select a tax of each class." msgstr "Només podeu seleccionar un impost de cada classe." -#: pkg/company.go:98 +#: pkg/company.go:100 msgctxt "input" msgid "Currency" msgstr "Moneda" -#: pkg/company.go:105 +#: pkg/company.go:107 msgctxt "input" msgid "Invoice number format" msgstr "Format del número de factura" -#: pkg/company.go:111 +#: pkg/company.go:113 +msgctxt "input" +msgid "Next invoice number" +msgstr "Següent número de factura" + +#: pkg/company.go:122 msgctxt "input" msgid "Legal disclaimer" msgstr "Nota legal" -#: pkg/company.go:129 +#: pkg/company.go:141 msgid "Selected currency is not valid." msgstr "Heu seleccionat una moneda que no és vàlida." -#: pkg/company.go:130 +#: pkg/company.go:142 msgid "Invoice number format can not be empty." msgstr "No podeu deixar el format del número de factura en blanc." -#: pkg/company.go:297 +#: pkg/company.go:143 +msgid "Next invoice number must be a number greater than zero." +msgstr "El següent número de factura ha de ser un número major a zero." + +#: pkg/company.go:350 msgctxt "input" msgid "Tax name" msgstr "Nom impost" -#: pkg/company.go:303 +#: pkg/company.go:356 msgctxt "input" msgid "Tax Class" msgstr "Classe d’impost" -#: pkg/company.go:306 +#: pkg/company.go:359 msgid "Select a tax class" msgstr "Escolliu una classe d’impost" -#: pkg/company.go:310 +#: pkg/company.go:363 msgctxt "input" msgid "Rate (%)" msgstr "Percentatge" -#: pkg/company.go:333 +#: pkg/company.go:386 msgid "Tax name can not be empty." msgstr "No podeu deixar el nom de l’impost en blanc." -#: pkg/company.go:334 +#: pkg/company.go:387 msgid "Selected tax class is not valid." msgstr "Heu seleccionat una classe d’impost que no és vàlida." -#: pkg/company.go:335 +#: pkg/company.go:388 msgid "Tax rate can not be empty." msgstr "No podeu deixar percentatge en blanc." -#: pkg/company.go:336 +#: pkg/company.go:389 msgid "Tax rate must be an integer between -99 and 99." msgstr "El percentatge ha de ser entre -99 i 99." -#: pkg/company.go:399 +#: pkg/company.go:452 msgctxt "input" msgid "Payment method name" msgstr "Nom del mètode de pagament" -#: pkg/company.go:405 +#: pkg/company.go:458 msgctxt "input" msgid "Instructions" msgstr "Instruccions" -#: pkg/company.go:423 +#: pkg/company.go:476 msgid "Payment method name can not be empty." msgstr "No podeu deixar el nom del mètode de pagament en blanc." -#: pkg/company.go:424 +#: pkg/company.go:477 msgid "Payment instructions can not be empty." msgstr "No podeu deixar les instruccions de pagament en blanc." +#: pkg/quote.go:161 pkg/quote.go:585 pkg/expenses.go:340 pkg/invoices.go:162 +#: pkg/invoices.go:584 +msgctxt "input" +msgid "Customer" +msgstr "Client" + +#: pkg/quote.go:162 pkg/expenses.go:341 pkg/invoices.go:163 +msgid "All customers" +msgstr "Tots els clients" + +#: pkg/quote.go:167 pkg/quote.go:579 +msgctxt "input" +msgid "Quotation Status" +msgstr "Estat del pressupost" + +#: pkg/quote.go:168 pkg/invoices.go:169 +msgid "All status" +msgstr "Tots els estats" + +#: pkg/quote.go:173 +msgctxt "input" +msgid "Quotation Number" +msgstr "Número de pressupost" + +#: pkg/quote.go:178 pkg/expenses.go:351 pkg/invoices.go:179 +msgctxt "input" +msgid "From Date" +msgstr "A partir de la data" + +#: pkg/quote.go:183 pkg/expenses.go:356 pkg/invoices.go:184 +msgctxt "input" +msgid "To Date" +msgstr "Fins la data" + +#: pkg/quote.go:197 +msgid "Quotations must have all the specified labels." +msgstr "Els pressuposts han de tenir totes les etiquetes." + +#: pkg/quote.go:202 +msgid "Quotations must have at least one of the specified labels." +msgstr "Els pressuposts han de tenir com a mínim una de les etiquetes." + +#: pkg/quote.go:451 +msgid "Select a customer to quote." +msgstr "Escolliu un client a pressupostar." + +#: pkg/quote.go:527 +msgid "quotations.zip" +msgstr "pressuposts.zip" + +#: pkg/quote.go:533 pkg/quote.go:1055 pkg/quote.go:1063 pkg/invoices.go:533 +#: pkg/invoices.go:1098 pkg/invoices.go:1106 +msgid "Invalid action" +msgstr "Acció invàlida." + +#: pkg/quote.go:590 +msgctxt "input" +msgid "Quotation Date" +msgstr "Data del pressupost" + +#: pkg/quote.go:596 +msgctxt "input" +msgid "Terms and conditions" +msgstr "Condicions d’acceptació" + +#: pkg/quote.go:601 pkg/invoices.go:596 +msgctxt "input" +msgid "Notes" +msgstr "Notes" + +#: pkg/quote.go:610 pkg/invoices.go:606 +msgctxt "input" +msgid "Payment Method" +msgstr "Mètode de pagament" + +#: pkg/quote.go:646 +msgid "Selected quotation status is not valid." +msgstr "Heu seleccionat un estat de pressupost que no és vàlid." + +#: pkg/quote.go:647 pkg/invoices.go:643 +msgid "Selected customer is not valid." +msgstr "Heu seleccionat un client que no és vàlid." + +#: pkg/quote.go:648 +msgid "Quotation date can not be empty." +msgstr "No podeu deixar la data del pressupost en blanc." + +#: pkg/quote.go:649 +msgid "Quotation date must be a valid date." +msgstr "La data del pressupost ha de ser vàlida." + +#: pkg/quote.go:651 pkg/invoices.go:647 +msgid "Selected payment method is not valid." +msgstr "Heu seleccionat un mètode de pagament que no és vàlid." + +#: pkg/quote.go:783 pkg/quote.go:788 pkg/invoices.go:814 pkg/invoices.go:819 +msgctxt "input" +msgid "Id" +msgstr "Identificador" + +#: pkg/quote.go:821 pkg/invoices.go:852 +msgctxt "input" +msgid "Quantity" +msgstr "Quantitat" + +#: pkg/quote.go:830 pkg/invoices.go:861 +msgctxt "input" +msgid "Discount (%)" +msgstr "Descompte (%)" + +#: pkg/quote.go:884 +msgid "Quotation product ID must be a number greater than zero." +msgstr "L’ID del producte de pressupost ha de ser un número major a zero." + +#: pkg/quote.go:887 pkg/invoices.go:918 +msgid "Product ID must be a positive number or zero." +msgstr "L’ID del producte ha de ser un número positiu o zero." + +#: pkg/quote.go:893 pkg/invoices.go:924 +msgid "Quantity can not be empty." +msgstr "No podeu deixar la quantitat en blanc." + +#: pkg/quote.go:894 pkg/invoices.go:925 +msgid "Quantity must be a number greater than zero." +msgstr "La quantitat ha de ser un número major a zero." + +#: pkg/quote.go:896 pkg/invoices.go:927 +msgid "Discount can not be empty." +msgstr "No podeu deixar el descompte en blanc." + +#: pkg/quote.go:897 pkg/invoices.go:928 +msgid "Discount must be a percentage between 0 and 100." +msgstr "El descompte ha de ser un percentatge entre 0 i 100." + #: pkg/profile.go:25 msgctxt "language option" msgid "Automatic" @@ -815,39 +1047,16 @@ msgstr "No podeu deixar l’import en blanc." msgid "Amount must be a number greater than zero." msgstr "L’import ha de ser un número major a zero." -#: pkg/expenses.go:340 pkg/invoices.go:162 pkg/invoices.go:584 -msgctxt "input" -msgid "Customer" -msgstr "Client" - -#: pkg/expenses.go:341 pkg/invoices.go:163 -msgid "All customers" -msgstr "Tots els clients" - #: pkg/expenses.go:346 pkg/invoices.go:174 msgctxt "input" msgid "Invoice Number" msgstr "Número de factura" -#: pkg/expenses.go:351 pkg/invoices.go:179 -msgctxt "input" -msgid "From Date" -msgstr "A partir de la data" - -#: pkg/expenses.go:356 pkg/invoices.go:184 -msgctxt "input" -msgid "To Date" -msgstr "Fins la data" - #: pkg/invoices.go:168 pkg/invoices.go:578 msgctxt "input" msgid "Invoice Status" msgstr "Estat de la factura" -#: pkg/invoices.go:169 -msgid "All status" -msgstr "Tots els estats" - #: pkg/invoices.go:426 msgid "Select a customer to bill." msgstr "Escolliu un client a facturar." @@ -856,75 +1065,18 @@ msgstr "Escolliu un client a facturar." msgid "invoices.zip" msgstr "factures.zip" -#: pkg/invoices.go:533 pkg/invoices.go:1090 pkg/invoices.go:1098 -msgid "Invalid action" -msgstr "Acció invàlida." - -#: pkg/invoices.go:596 -msgctxt "input" -msgid "Notes" -msgstr "Notes" - -#: pkg/invoices.go:606 -msgctxt "input" -msgid "Payment Method" -msgstr "Mètode de pagament" - #: pkg/invoices.go:642 msgid "Selected invoice status is not valid." msgstr "Heu seleccionat un estat de factura que no és vàlid." -#: pkg/invoices.go:643 -msgid "Selected customer is not valid." -msgstr "Heu seleccionat un client que no és vàlid." - #: pkg/invoices.go:644 msgid "Invoice date can not be empty." msgstr "No podeu deixar la data de la factura en blanc." -#: pkg/invoices.go:647 -msgid "Selected payment method is not valid." -msgstr "Heu seleccionat un mètode de pagament que no és vàlid." - -#: pkg/invoices.go:806 pkg/invoices.go:811 -msgctxt "input" -msgid "Id" -msgstr "Identificador" - -#: pkg/invoices.go:844 -msgctxt "input" -msgid "Quantity" -msgstr "Quantitat" - -#: pkg/invoices.go:853 -msgctxt "input" -msgid "Discount (%)" -msgstr "Descompte (%)" - -#: pkg/invoices.go:907 +#: pkg/invoices.go:915 msgid "Invoice product ID must be a number greater than zero." msgstr "L’ID del producte de factura ha de ser un número major a zero." -#: pkg/invoices.go:910 -msgid "Product ID must be a positive number or zero." -msgstr "L’ID del producte ha de ser un número positiu o zero." - -#: pkg/invoices.go:916 -msgid "Quantity can not be empty." -msgstr "No podeu deixar la quantitat en blanc." - -#: pkg/invoices.go:917 -msgid "Quantity must be a number greater than zero." -msgstr "La quantitat ha de ser un número major a zero." - -#: pkg/invoices.go:919 -msgid "Discount can not be empty." -msgstr "No podeu deixar el descompte en blanc." - -#: pkg/invoices.go:920 -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:238 msgctxt "input" msgid "Business name" diff --git a/po/es.po b/po/es.po index 01f709c..4f3fd4b 100644 --- a/po/es.po +++ b/po/es.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-05-29 00:02+0200\n" +"POT-Creation-Date: 2023-06-07 16:05+0200\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -25,12 +25,15 @@ msgstr "Añadir productos a la factura" #: web/template/invoices/products.gohtml:9 web/template/invoices/new.gohtml:9 #: web/template/invoices/index.gohtml:9 web/template/invoices/view.gohtml:9 -#: web/template/invoices/edit.gohtml:9 web/template/contacts/new.gohtml:9 -#: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10 -#: web/template/profile.gohtml:9 web/template/expenses/new.gohtml:10 -#: web/template/expenses/index.gohtml:10 web/template/expenses/edit.gohtml:10 -#: web/template/tax-details.gohtml:9 web/template/products/new.gohtml:9 -#: web/template/products/index.gohtml:9 web/template/products/edit.gohtml:10 +#: web/template/invoices/edit.gohtml:9 web/template/quotes/products.gohtml:9 +#: web/template/quotes/new.gohtml:9 web/template/quotes/index.gohtml:9 +#: web/template/quotes/view.gohtml:9 web/template/quotes/edit.gohtml:9 +#: web/template/contacts/new.gohtml:9 web/template/contacts/index.gohtml:9 +#: web/template/contacts/edit.gohtml:10 web/template/profile.gohtml:9 +#: web/template/expenses/new.gohtml:10 web/template/expenses/index.gohtml:10 +#: web/template/expenses/edit.gohtml:10 web/template/tax-details.gohtml:9 +#: web/template/products/new.gohtml:9 web/template/products/index.gohtml:9 +#: web/template/products/edit.gohtml:10 msgctxt "title" msgid "Home" msgstr "Inicio" @@ -49,60 +52,70 @@ msgid "New Invoice" msgstr "Nueva factura" #: web/template/invoices/products.gohtml:48 +#: web/template/quotes/products.gohtml:48 msgctxt "product" msgid "All" msgstr "Todos" #: web/template/invoices/products.gohtml:49 -#: web/template/products/index.gohtml:40 +#: web/template/quotes/products.gohtml:49 web/template/products/index.gohtml:40 msgctxt "title" msgid "Name" msgstr "Nombre" #: web/template/invoices/products.gohtml:50 -#: web/template/invoices/view.gohtml:62 web/template/products/index.gohtml:42 +#: web/template/invoices/view.gohtml:62 web/template/quotes/products.gohtml:50 +#: web/template/quotes/view.gohtml:62 web/template/products/index.gohtml:42 msgctxt "title" msgid "Price" msgstr "Precio" #: web/template/invoices/products.gohtml:64 -#: web/template/products/index.gohtml:82 +#: web/template/quotes/products.gohtml:64 web/template/products/index.gohtml:82 msgid "No products added yet." msgstr "No hay productos." #: web/template/invoices/products.gohtml:72 web/template/invoices/new.gohtml:83 -#: web/template/invoices/edit.gohtml:84 +#: web/template/invoices/edit.gohtml:84 web/template/quotes/products.gohtml:72 +#: web/template/quotes/new.gohtml:84 web/template/quotes/edit.gohtml:85 msgctxt "action" msgid "Add products" msgstr "Añadir productos" #: web/template/invoices/new.gohtml:27 web/template/invoices/edit.gohtml:27 +#: web/template/quotes/new.gohtml:27 web/template/quotes/edit.gohtml:27 msgid "Product “%s” removed" msgstr "Se ha borrado el producto «%s»" #: web/template/invoices/new.gohtml:31 web/template/invoices/edit.gohtml:31 +#: web/template/quotes/new.gohtml:31 web/template/quotes/edit.gohtml:31 msgctxt "action" msgid "Undo" msgstr "Deshacer" #: web/template/invoices/new.gohtml:60 web/template/invoices/view.gohtml:67 -#: web/template/invoices/edit.gohtml:61 +#: web/template/invoices/edit.gohtml:61 web/template/quotes/new.gohtml:61 +#: web/template/quotes/view.gohtml:67 web/template/quotes/edit.gohtml:62 msgctxt "title" msgid "Subtotal" msgstr "Subtotal" #: web/template/invoices/new.gohtml:70 web/template/invoices/view.gohtml:71 #: web/template/invoices/view.gohtml:111 web/template/invoices/edit.gohtml:71 +#: web/template/quotes/new.gohtml:71 web/template/quotes/view.gohtml:71 +#: web/template/quotes/view.gohtml:111 web/template/quotes/edit.gohtml:72 msgctxt "title" msgid "Total" msgstr "Total" #: web/template/invoices/new.gohtml:87 web/template/invoices/edit.gohtml:88 +#: web/template/quotes/new.gohtml:88 web/template/quotes/edit.gohtml:89 msgctxt "action" msgid "Update" msgstr "Actualizar" #: web/template/invoices/new.gohtml:90 web/template/invoices/edit.gohtml:91 +#: web/template/quotes/new.gohtml:91 web/template/quotes/edit.gohtml:92 #: web/template/contacts/new.gohtml:39 web/template/contacts/edit.gohtml:43 #: web/template/expenses/new.gohtml:33 web/template/expenses/edit.gohtml:38 #: web/template/products/new.gohtml:30 web/template/products/edit.gohtml:36 @@ -121,8 +134,8 @@ msgid "New invoice" msgstr "Nueva factura" #: web/template/invoices/index.gohtml:43 web/template/dashboard.gohtml:23 -#: web/template/contacts/index.gohtml:34 web/template/expenses/index.gohtml:36 -#: web/template/products/index.gohtml:34 +#: web/template/quotes/index.gohtml:43 web/template/contacts/index.gohtml:34 +#: web/template/expenses/index.gohtml:36 web/template/products/index.gohtml:34 msgctxt "action" msgid "Filter" msgstr "Filtrar" @@ -133,6 +146,7 @@ msgid "All" msgstr "Todas" #: web/template/invoices/index.gohtml:50 web/template/invoices/view.gohtml:34 +#: web/template/quotes/index.gohtml:50 web/template/quotes/view.gohtml:34 msgctxt "title" msgid "Date" msgstr "Fecha" @@ -140,36 +154,41 @@ msgstr "Fecha" #: web/template/invoices/index.gohtml:51 msgctxt "title" msgid "Invoice Num." -msgstr "Nº factura" +msgstr "N.º factura" -#: web/template/invoices/index.gohtml:52 web/template/contacts/index.gohtml:40 +#: web/template/invoices/index.gohtml:52 web/template/quotes/index.gohtml:52 +#: web/template/contacts/index.gohtml:40 msgctxt "title" msgid "Customer" msgstr "Cliente" -#: web/template/invoices/index.gohtml:53 +#: web/template/invoices/index.gohtml:53 web/template/quotes/index.gohtml:53 msgctxt "title" msgid "Status" msgstr "Estado" -#: web/template/invoices/index.gohtml:54 web/template/contacts/index.gohtml:43 -#: web/template/expenses/index.gohtml:46 web/template/products/index.gohtml:41 +#: web/template/invoices/index.gohtml:54 web/template/quotes/index.gohtml:54 +#: web/template/contacts/index.gohtml:43 web/template/expenses/index.gohtml:46 +#: web/template/products/index.gohtml:41 msgctxt "title" msgid "Tags" msgstr "Etiquetes" -#: web/template/invoices/index.gohtml:55 web/template/expenses/index.gohtml:47 +#: web/template/invoices/index.gohtml:55 web/template/quotes/index.gohtml:55 +#: web/template/expenses/index.gohtml:47 msgctxt "title" msgid "Amount" msgstr "Importe" -#: web/template/invoices/index.gohtml:56 web/template/expenses/index.gohtml:48 +#: web/template/invoices/index.gohtml:56 web/template/quotes/index.gohtml:56 +#: web/template/expenses/index.gohtml:48 msgctxt "title" msgid "Download" msgstr "Descargar" -#: web/template/invoices/index.gohtml:57 web/template/contacts/index.gohtml:44 -#: web/template/expenses/index.gohtml:49 web/template/products/index.gohtml:43 +#: web/template/invoices/index.gohtml:57 web/template/quotes/index.gohtml:57 +#: web/template/contacts/index.gohtml:44 web/template/expenses/index.gohtml:49 +#: web/template/products/index.gohtml:43 msgctxt "title" msgid "Actions" msgstr "Acciones" @@ -180,6 +199,7 @@ msgid "Select invoice %v" msgstr "Seleccionar factura %v" #: web/template/invoices/index.gohtml:119 web/template/invoices/view.gohtml:19 +#: web/template/quotes/index.gohtml:119 web/template/quotes/view.gohtml:19 #: web/template/contacts/index.gohtml:74 web/template/expenses/index.gohtml:88 #: web/template/products/index.gohtml:72 msgctxt "action" @@ -187,6 +207,7 @@ msgid "Edit" msgstr "Editar" #: web/template/invoices/index.gohtml:127 web/template/invoices/view.gohtml:16 +#: web/template/quotes/index.gohtml:127 web/template/quotes/view.gohtml:16 msgctxt "action" msgid "Duplicate" msgstr "Duplicar" @@ -205,22 +226,22 @@ msgctxt "action" msgid "Download invoice" msgstr "Descargar factura" -#: web/template/invoices/view.gohtml:61 +#: web/template/invoices/view.gohtml:61 web/template/quotes/view.gohtml:61 msgctxt "title" msgid "Concept" msgstr "Concepto" -#: web/template/invoices/view.gohtml:64 +#: web/template/invoices/view.gohtml:64 web/template/quotes/view.gohtml:64 msgctxt "title" msgid "Discount" msgstr "Descuento" -#: web/template/invoices/view.gohtml:66 +#: web/template/invoices/view.gohtml:66 web/template/quotes/view.gohtml:66 msgctxt "title" msgid "Units" msgstr "Unidades" -#: web/template/invoices/view.gohtml:101 +#: web/template/invoices/view.gohtml:101 web/template/quotes/view.gohtml:101 msgctxt "title" msgid "Tax Base" msgstr "Base imponible" @@ -235,7 +256,7 @@ msgctxt "input" msgid "(Max. %s)" msgstr "(Máx. %s)" -#: web/template/form.gohtml:171 +#: web/template/form.gohtml:194 msgctxt "action" msgid "Filters" msgstr "Filtrar" @@ -275,6 +296,68 @@ msgctxt "term" msgid "Net Income" msgstr "Ingresos netos" +#: web/template/quotes/products.gohtml:2 web/template/quotes/products.gohtml:23 +msgctxt "title" +msgid "Add Products to Quotation" +msgstr "Añadir productos al presupuesto" + +#: web/template/quotes/products.gohtml:10 web/template/quotes/new.gohtml:10 +#: web/template/quotes/index.gohtml:2 web/template/quotes/index.gohtml:10 +#: web/template/quotes/view.gohtml:10 web/template/quotes/edit.gohtml:10 +msgctxt "title" +msgid "Quotations" +msgstr "Presupuestos" + +#: web/template/quotes/products.gohtml:12 web/template/quotes/new.gohtml:2 +#: web/template/quotes/new.gohtml:11 web/template/quotes/new.gohtml:19 +msgctxt "title" +msgid "New Quotation" +msgstr "Nuevo presupuesto" + +#: web/template/quotes/index.gohtml:19 +msgctxt "action" +msgid "Download quotations" +msgstr "Descargar presupuestos" + +#: web/template/quotes/index.gohtml:21 +msgctxt "action" +msgid "New quotation" +msgstr "Nuevo presupuesto" + +#: web/template/quotes/index.gohtml:49 +msgctxt "quote" +msgid "All" +msgstr "Todos" + +#: web/template/quotes/index.gohtml:51 +msgctxt "title" +msgid "Quotation Num." +msgstr "N.º de presupuesto" + +#: web/template/quotes/index.gohtml:64 +msgctxt "action" +msgid "Select quotation %v" +msgstr "Seleccionar presupuesto %v" + +#: web/template/quotes/index.gohtml:137 +msgid "No quotations added yet." +msgstr "No hay presupuestos." + +#: web/template/quotes/view.gohtml:2 web/template/quotes/view.gohtml:33 +msgctxt "title" +msgid "Quotation %s" +msgstr "Estado del presupuesto" + +#: web/template/quotes/view.gohtml:22 +msgctxt "action" +msgid "Download quotation" +msgstr "Descargar presupuesto" + +#: web/template/quotes/edit.gohtml:2 web/template/quotes/edit.gohtml:19 +msgctxt "title" +msgid "Edit Quotation “%s”" +msgstr "Edición del presupuesto «%s»" + #: web/template/app.gohtml:23 msgctxt "menu" msgid "Account" @@ -297,20 +380,25 @@ msgstr "Panel" #: web/template/app.gohtml:47 msgctxt "nav" +msgid "Quotations" +msgstr "Presupuestos" + +#: web/template/app.gohtml:48 +msgctxt "nav" msgid "Invoices" msgstr "Facturas" -#: web/template/app.gohtml:48 +#: web/template/app.gohtml:49 msgctxt "nav" msgid "Expenses" msgstr "Gastos" -#: web/template/app.gohtml:49 +#: web/template/app.gohtml:50 msgctxt "nav" msgid "Products" msgstr "Productos" -#: web/template/app.gohtml:50 +#: web/template/app.gohtml:51 msgctxt "nav" msgid "Contacts" msgstr "Contactos" @@ -382,7 +470,7 @@ msgctxt "title" msgid "Language" msgstr "Idioma" -#: web/template/profile.gohtml:39 web/template/tax-details.gohtml:172 +#: web/template/profile.gohtml:39 web/template/tax-details.gohtml:173 msgctxt "action" msgid "Save changes" msgstr "Guardar cambios" @@ -449,54 +537,54 @@ msgctxt "title" msgid "Invoicing" msgstr "Facturación" -#: web/template/tax-details.gohtml:53 +#: web/template/tax-details.gohtml:54 msgid "Are you sure?" msgstr "¿Estáis seguro?" -#: web/template/tax-details.gohtml:59 +#: web/template/tax-details.gohtml:60 msgctxt "title" msgid "Tax Name" msgstr "Nombre impuesto" -#: web/template/tax-details.gohtml:60 +#: web/template/tax-details.gohtml:61 msgctxt "title" msgid "Rate (%)" msgstr "Porcentaje" -#: web/template/tax-details.gohtml:61 +#: web/template/tax-details.gohtml:62 msgctxt "title" msgid "Class" msgstr "Clase" -#: web/template/tax-details.gohtml:85 +#: web/template/tax-details.gohtml:86 msgid "No taxes added yet." msgstr "No hay impuestos." -#: web/template/tax-details.gohtml:91 web/template/tax-details.gohtml:152 +#: web/template/tax-details.gohtml:92 web/template/tax-details.gohtml:153 msgctxt "title" msgid "New Line" msgstr "Nueva línea" -#: web/template/tax-details.gohtml:105 +#: web/template/tax-details.gohtml:106 msgctxt "action" msgid "Add new tax" msgstr "Añadir nuevo impuesto" -#: web/template/tax-details.gohtml:121 +#: web/template/tax-details.gohtml:122 msgctxt "title" msgid "Payment Method" msgstr "Método de pago" -#: web/template/tax-details.gohtml:122 +#: web/template/tax-details.gohtml:123 msgctxt "title" msgid "Instructions" msgstr "Instrucciones" -#: web/template/tax-details.gohtml:146 +#: web/template/tax-details.gohtml:147 msgid "No payment methods added yet." msgstr "No hay métodos de pago." -#: web/template/tax-details.gohtml:164 +#: web/template/tax-details.gohtml:165 msgctxt "action" msgid "Add new payment method" msgstr "Añadir nuevo método de pago" @@ -553,26 +641,27 @@ 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:164 pkg/products.go:263 pkg/invoices.go:816 +#: pkg/products.go:164 pkg/products.go:263 pkg/quote.go:793 pkg/invoices.go:824 #: pkg/contacts.go:135 msgctxt "input" msgid "Name" msgstr "Nombre" -#: pkg/products.go:169 pkg/products.go:290 pkg/expenses.go:202 -#: pkg/expenses.go:361 pkg/invoices.go:189 pkg/invoices.go:601 -#: pkg/invoices.go:1115 pkg/contacts.go:140 pkg/contacts.go:325 +#: pkg/products.go:169 pkg/products.go:290 pkg/quote.go:188 pkg/quote.go:606 +#: pkg/expenses.go:202 pkg/expenses.go:361 pkg/invoices.go:189 +#: pkg/invoices.go:601 pkg/invoices.go:1123 pkg/contacts.go:140 +#: pkg/contacts.go:325 msgctxt "input" msgid "Tags" msgstr "Etiquetes" -#: pkg/products.go:173 pkg/expenses.go:365 pkg/invoices.go:193 +#: pkg/products.go:173 pkg/quote.go:192 pkg/expenses.go:365 pkg/invoices.go:193 #: pkg/contacts.go:144 msgctxt "input" msgid "Tags Condition" msgstr "Condición de las etiquetas" -#: pkg/products.go:177 pkg/expenses.go:369 pkg/invoices.go:197 +#: pkg/products.go:177 pkg/quote.go:196 pkg/expenses.go:369 pkg/invoices.go:197 #: pkg/contacts.go:148 msgctxt "tag condition" msgid "All" @@ -583,7 +672,7 @@ msgstr "Todas" msgid "Invoices must have all the specified labels." msgstr "Las facturas deben tener todas las etiquetas." -#: pkg/products.go:182 pkg/expenses.go:374 pkg/invoices.go:202 +#: pkg/products.go:182 pkg/quote.go:201 pkg/expenses.go:374 pkg/invoices.go:202 #: pkg/contacts.go:153 msgctxt "tag condition" msgid "Any" @@ -592,121 +681,264 @@ msgstr "Cualquiera" #: pkg/products.go:183 pkg/expenses.go:375 pkg/invoices.go:203 #: pkg/contacts.go:154 msgid "Invoices must have at least one of the specified labels." -msgstr "Las facturas debent tener como mínimo una de las etiquetas." +msgstr "Las facturas deben tener como mínimo una de las etiquetas." -#: pkg/products.go:269 pkg/invoices.go:830 +#: pkg/products.go:269 pkg/quote.go:807 pkg/invoices.go:838 msgctxt "input" msgid "Description" msgstr "Descripción" -#: pkg/products.go:274 pkg/invoices.go:834 +#: pkg/products.go:274 pkg/quote.go:811 pkg/invoices.go:842 msgctxt "input" msgid "Price" msgstr "Precio" -#: pkg/products.go:284 pkg/expenses.go:181 pkg/invoices.go:863 +#: pkg/products.go:284 pkg/quote.go:840 pkg/expenses.go:181 pkg/invoices.go:871 msgctxt "input" msgid "Taxes" msgstr "Impuestos" -#: pkg/products.go:309 pkg/profile.go:92 pkg/invoices.go:912 +#: pkg/products.go:309 pkg/quote.go:889 pkg/profile.go:92 pkg/invoices.go:920 msgid "Name can not be empty." msgstr "No podéis dejar el nombre en blanco." -#: pkg/products.go:310 pkg/invoices.go:913 +#: pkg/products.go:310 pkg/quote.go:890 pkg/invoices.go:921 msgid "Price can not be empty." msgstr "No podéis dejar el precio en blanco." -#: pkg/products.go:311 pkg/invoices.go:914 +#: pkg/products.go:311 pkg/quote.go:891 pkg/invoices.go:922 msgid "Price must be a number greater than zero." msgstr "El precio tiene que ser un número mayor a cero." -#: pkg/products.go:313 pkg/expenses.go:227 pkg/expenses.go:232 -#: pkg/invoices.go:922 +#: pkg/products.go:313 pkg/quote.go:899 pkg/expenses.go:227 pkg/expenses.go:232 +#: pkg/invoices.go:930 msgid "Selected tax is not valid." msgstr "Habéis escogido un impuesto que no es válido." -#: pkg/products.go:314 pkg/expenses.go:228 pkg/expenses.go:233 -#: pkg/invoices.go:923 +#: pkg/products.go:314 pkg/quote.go:900 pkg/expenses.go:228 pkg/expenses.go:233 +#: pkg/invoices.go:931 msgid "You can only select a tax of each class." msgstr "Solo podéis escoger un impuesto de cada clase." -#: pkg/company.go:98 +#: pkg/company.go:100 msgctxt "input" msgid "Currency" msgstr "Moneda" -#: pkg/company.go:105 +#: pkg/company.go:107 msgctxt "input" msgid "Invoice number format" msgstr "Formato del número de factura" -#: pkg/company.go:111 +#: pkg/company.go:113 +msgctxt "input" +msgid "Next invoice number" +msgstr "Número de presupuesto" + +#: pkg/company.go:122 msgctxt "input" msgid "Legal disclaimer" msgstr "Nota legal" -#: pkg/company.go:129 +#: pkg/company.go:141 msgid "Selected currency is not valid." msgstr "Habéis escogido una moneda que no es válida." -#: pkg/company.go:130 +#: pkg/company.go:142 msgid "Invoice number format can not be empty." msgstr "No podéis dejar el formato del número de factura en blanco." -#: pkg/company.go:297 +#: pkg/company.go:143 +msgid "Next invoice number must be a number greater than zero." +msgstr "El siguiente número de factura tiene que ser un número mayor a cero." + +#: pkg/company.go:350 msgctxt "input" msgid "Tax name" msgstr "Nombre impuesto" -#: pkg/company.go:303 +#: pkg/company.go:356 msgctxt "input" msgid "Tax Class" msgstr "Clase de impuesto" -#: pkg/company.go:306 +#: pkg/company.go:359 msgid "Select a tax class" msgstr "Escoged una clase de impuesto" -#: pkg/company.go:310 +#: pkg/company.go:363 msgctxt "input" msgid "Rate (%)" msgstr "Porcentaje" -#: pkg/company.go:333 +#: pkg/company.go:386 msgid "Tax name can not be empty." msgstr "No podéis dejar el nombre del impuesto en blanco." -#: pkg/company.go:334 +#: pkg/company.go:387 msgid "Selected tax class is not valid." msgstr "Habéis escogido una clase impuesto que no es válida." -#: pkg/company.go:335 +#: pkg/company.go:388 msgid "Tax rate can not be empty." msgstr "No podéis dejar el porcentaje en blanco." -#: pkg/company.go:336 +#: pkg/company.go:389 msgid "Tax rate must be an integer between -99 and 99." msgstr "El porcentaje tiene que estar entre -99 y 99." -#: pkg/company.go:399 +#: pkg/company.go:452 msgctxt "input" msgid "Payment method name" msgstr "Nombre del método de pago" -#: pkg/company.go:405 +#: pkg/company.go:458 msgctxt "input" msgid "Instructions" msgstr "Instrucciones" -#: pkg/company.go:423 +#: pkg/company.go:476 msgid "Payment method name can not be empty." msgstr "No podéis dejar el nombre del método de pago en blanco." -#: pkg/company.go:424 +#: pkg/company.go:477 msgid "Payment instructions can not be empty." msgstr "No podéis dejar las instrucciones de pago en blanco." +#: pkg/quote.go:161 pkg/quote.go:585 pkg/expenses.go:340 pkg/invoices.go:162 +#: pkg/invoices.go:584 +msgctxt "input" +msgid "Customer" +msgstr "Cliente" + +#: pkg/quote.go:162 pkg/expenses.go:341 pkg/invoices.go:163 +msgid "All customers" +msgstr "Todos los clientes" + +#: pkg/quote.go:167 pkg/quote.go:579 +msgctxt "input" +msgid "Quotation Status" +msgstr "Estado del presupuesto" + +#: pkg/quote.go:168 pkg/invoices.go:169 +msgid "All status" +msgstr "Todos los estados" + +#: pkg/quote.go:173 +msgctxt "input" +msgid "Quotation Number" +msgstr "Número de presupuesto" + +#: pkg/quote.go:178 pkg/expenses.go:351 pkg/invoices.go:179 +msgctxt "input" +msgid "From Date" +msgstr "A partir de la fecha" + +#: pkg/quote.go:183 pkg/expenses.go:356 pkg/invoices.go:184 +msgctxt "input" +msgid "To Date" +msgstr "Hasta la fecha" + +#: pkg/quote.go:197 +msgid "Quotations must have all the specified labels." +msgstr "Los presupuestos deben tener todas las etiquetas." + +#: pkg/quote.go:202 +msgid "Quotations must have at least one of the specified labels." +msgstr "Los presupuestos deben tener como mínimo una de las etiquetas." + +#: pkg/quote.go:451 +msgid "Select a customer to quote." +msgstr "Escoged un cliente a presupuestar." + +#: pkg/quote.go:527 +msgid "quotations.zip" +msgstr "presupuestos.zip" + +#: pkg/quote.go:533 pkg/quote.go:1055 pkg/quote.go:1063 pkg/invoices.go:533 +#: pkg/invoices.go:1098 pkg/invoices.go:1106 +msgid "Invalid action" +msgstr "Acción inválida." + +#: pkg/quote.go:590 +msgctxt "input" +msgid "Quotation Date" +msgstr "Fecha del presupuesto" + +#: pkg/quote.go:596 +msgctxt "input" +msgid "Terms and conditions" +msgstr "Condiciones de aceptación" + +#: pkg/quote.go:601 pkg/invoices.go:596 +msgctxt "input" +msgid "Notes" +msgstr "Notas" + +#: pkg/quote.go:610 pkg/invoices.go:606 +msgctxt "input" +msgid "Payment Method" +msgstr "Método de pago" + +#: pkg/quote.go:646 +msgid "Selected quotation status is not valid." +msgstr "Habéis escogido un estado de presupuesto que no es válido." + +#: pkg/quote.go:647 pkg/invoices.go:643 +msgid "Selected customer is not valid." +msgstr "Habéis escogido un cliente que no es válido." + +#: pkg/quote.go:648 +msgid "Quotation date can not be empty." +msgstr "No podéis dejar la fecha del presupuesto en blanco." + +#: pkg/quote.go:649 +msgid "Quotation date must be a valid date." +msgstr "La fecha de presupuesto debe ser válida." + +#: pkg/quote.go:651 pkg/invoices.go:647 +msgid "Selected payment method is not valid." +msgstr "Habéis escogido un método de pago que no es válido." + +#: pkg/quote.go:783 pkg/quote.go:788 pkg/invoices.go:814 pkg/invoices.go:819 +msgctxt "input" +msgid "Id" +msgstr "Identificador" + +#: pkg/quote.go:821 pkg/invoices.go:852 +msgctxt "input" +msgid "Quantity" +msgstr "Cantidad" + +#: pkg/quote.go:830 pkg/invoices.go:861 +msgctxt "input" +msgid "Discount (%)" +msgstr "Descuento (%)" + +#: pkg/quote.go:884 +msgid "Quotation product ID must be a number greater than zero." +msgstr "El ID de producto de presupuesto tiene que ser un número mayor a cero." + +#: pkg/quote.go:887 pkg/invoices.go:918 +msgid "Product ID must be a positive number or zero." +msgstr "El ID de producto tiene que ser un número positivo o cero." + +#: pkg/quote.go:893 pkg/invoices.go:924 +msgid "Quantity can not be empty." +msgstr "No podéis dejar la cantidad en blanco." + +#: pkg/quote.go:894 pkg/invoices.go:925 +msgid "Quantity must be a number greater than zero." +msgstr "La cantidad tiene que ser un número mayor a cero." + +#: pkg/quote.go:896 pkg/invoices.go:927 +msgid "Discount can not be empty." +msgstr "No podéis dejar el descuento en blanco." + +#: pkg/quote.go:897 pkg/invoices.go:928 +msgid "Discount must be a percentage between 0 and 100." +msgstr "El descuento tiene que ser un porcentaje entre 0 y 100." + #: pkg/profile.go:25 msgctxt "language option" msgid "Automatic" @@ -815,39 +1047,16 @@ msgstr "No podéis dejar el importe en blanco." msgid "Amount must be a number greater than zero." msgstr "El importe tiene que ser un número mayor a cero." -#: pkg/expenses.go:340 pkg/invoices.go:162 pkg/invoices.go:584 -msgctxt "input" -msgid "Customer" -msgstr "Cliente" - -#: pkg/expenses.go:341 pkg/invoices.go:163 -msgid "All customers" -msgstr "Todos los clientes" - #: pkg/expenses.go:346 pkg/invoices.go:174 msgctxt "input" msgid "Invoice Number" msgstr "Número de factura" -#: pkg/expenses.go:351 pkg/invoices.go:179 -msgctxt "input" -msgid "From Date" -msgstr "A partir de la fecha" - -#: pkg/expenses.go:356 pkg/invoices.go:184 -msgctxt "input" -msgid "To Date" -msgstr "Hasta la fecha" - #: pkg/invoices.go:168 pkg/invoices.go:578 msgctxt "input" msgid "Invoice Status" msgstr "Estado de la factura" -#: pkg/invoices.go:169 -msgid "All status" -msgstr "Todos los estados" - #: pkg/invoices.go:426 msgid "Select a customer to bill." msgstr "Escoged un cliente a facturar." @@ -856,75 +1065,18 @@ msgstr "Escoged un cliente a facturar." msgid "invoices.zip" msgstr "facturas.zip" -#: pkg/invoices.go:533 pkg/invoices.go:1090 pkg/invoices.go:1098 -msgid "Invalid action" -msgstr "Acción inválida." - -#: pkg/invoices.go:596 -msgctxt "input" -msgid "Notes" -msgstr "Notas" - -#: pkg/invoices.go:606 -msgctxt "input" -msgid "Payment Method" -msgstr "Método de pago" - #: pkg/invoices.go:642 msgid "Selected invoice status is not valid." msgstr "Habéis escogido un estado de factura que no es válido." -#: pkg/invoices.go:643 -msgid "Selected customer is not valid." -msgstr "Habéis escogido un cliente que no es válido." - #: pkg/invoices.go:644 msgid "Invoice date can not be empty." msgstr "No podéis dejar la fecha de la factura en blanco." -#: pkg/invoices.go:647 -msgid "Selected payment method is not valid." -msgstr "Habéis escogido un método de pago que no es válido." - -#: pkg/invoices.go:806 pkg/invoices.go:811 -msgctxt "input" -msgid "Id" -msgstr "Identificador" - -#: pkg/invoices.go:844 -msgctxt "input" -msgid "Quantity" -msgstr "Cantidad" - -#: pkg/invoices.go:853 -msgctxt "input" -msgid "Discount (%)" -msgstr "Descuento (%)" - -#: pkg/invoices.go:907 +#: pkg/invoices.go:915 msgid "Invoice product ID must be a number greater than zero." msgstr "El ID de producto de factura tiene que ser un número mayor a cero." -#: pkg/invoices.go:910 -msgid "Product ID must be a positive number or zero." -msgstr "El ID de producto tiene que ser un número positivo o cero." - -#: pkg/invoices.go:916 -msgid "Quantity can not be empty." -msgstr "No podéis dejar la cantidad en blanco." - -#: pkg/invoices.go:917 -msgid "Quantity must be a number greater than zero." -msgstr "La cantidad tiene que ser un número mayor a cero." - -#: pkg/invoices.go:919 -msgid "Discount can not be empty." -msgstr "No podéis dejar el descuento en blanco." - -#: pkg/invoices.go:920 -msgid "Discount must be a percentage between 0 and 100." -msgstr "El descuento tiene que ser un porcentaje entre 0 y 100." - #: pkg/contacts.go:238 msgctxt "input" msgid "Business name" diff --git a/web/static/numerus.css b/web/static/numerus.css index 5b1f134..92616fe 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -577,28 +577,33 @@ main > nav { /* Invoice */ +.new-quote-product input, .new-invoice-product input { width: 100%; } +.new-quote-product, .new-invoice-product { display: grid; grid-template-columns: repeat(4, 1fr); position: relative; } +.new-quote-product .delete-product, .new-invoice-product .delete-product { position: absolute; right: 0; top: .75rem; } +.new-quote-product .input:nth-of-type(5), .new-invoice-product .input:nth-of-type(5) { grid-column-start: 1; grid-column-end: 4; } +.new-quote-product textarea, .new-invoice-product textarea { width: 100%; height: 100%; @@ -608,23 +613,28 @@ main > nav { text-align: right; } +.quote-download, .invoice-download { text-align: center; } +.quote-download a, .invoice-download a { color: inherit; text-decoration: none; } +.quote-status, .invoice-status { position: relative; } +.quote-status summary, .invoice-status summary { height: 3rem; } +.quote-status ul, .invoice-status ul { position: absolute; top: 0; @@ -635,29 +645,35 @@ main > nav { gap: 1rem; } +.quote-status button, .invoice-status button { border: 0; min-width: 15rem; } +[class^='quote-status-'], [class^='invoice-status-'] { text-align: center; text-transform: uppercase; cursor: pointer; } +.quote-status-created, .invoice-status-created { background-color: var(--numerus--color--light-blue); } +.quote-status-sent, .invoice-status-sent { background-color: var(--numerus--color--hay); } +.quote-status-accepted, .invoice-status-paid { background-color: var(--numerus--color--light-green); } +.quote-status-rejected, .invoice-status-unpaid { background-color: var(--numerus--color--rosy); } @@ -670,12 +686,14 @@ main > nav { right: 0; } -.invoice-data, .product-data, .expenses-data { +.invoice-data, .quote-data, .product-data, .expenses-data { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; } +.quote-data .input:last-child, +.quote-data .input:nth-child(6), .invoice-data .input:last-child { grid-column-start: 1; grid-column-end: 5; diff --git a/web/template/app.gohtml b/web/template/app.gohtml index 67709f9..6fa82ca 100644 --- a/web/template/app.gohtml +++ b/web/template/app.gohtml @@ -44,6 +44,7 @@ +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editQuotationPage*/ -}} +
+

{{ printf (pgettext "Edit Quotation “%s”" "title") .Number }}

+
+ {{ csrfToken }} + + {{ with .Form -}} + {{ if .RemovedProduct -}} +
+ {{ with .RemovedProduct -}} +

{{printf (gettext "Product “%s” removed") .Name}}

+ + {{ template "hidden-field" .QuoteProductId }} + {{ template "hidden-field" .ProductId }} + {{ template "hidden-field" .Name }} + {{ template "hidden-field" .Price }} + {{ template "hidden-field" .Quantity }} + {{ template "hidden-field" .Discount }} + {{ template "hidden-field" .Description }} + {{ template "hidden-select-field" .Tax }} + {{- end }} +
+ {{- end }} + +
+ {{ template "select-field" .Customer }} + {{ template "hidden-field" .Date }} + {{ template "tags-field" .Tags }} + {{ template "select-field" .PaymentMethod }} + {{ template "select-field" .QuoteStatus }} + {{ template "input-field" .TermsAndConditions }} + {{ template "input-field" .Notes }} +
+ + {{- range $product := .Products }} + {{ template "quote-product-form" . }} + {{- end }} + {{- end }} + + + + + + + + {{- range $tax := .Taxes }} + + + + + {{- end }} + + + + + +
{{(pgettext "Subtotal" "title")}}{{ .Subtotal | formatPrice }}
{{ index . 0 }}{{ index . 1 | formatPrice }}
{{(pgettext "Total" "title")}}{{ .Total | formatPrice }}
+ +
+ + + +
+ +
+
+ + +{{- end }} diff --git a/web/template/quotes/index.gohtml b/web/template/quotes/index.gohtml new file mode 100644 index 0000000..e2e03d3 --- /dev/null +++ b/web/template/quotes/index.gohtml @@ -0,0 +1,142 @@ +{{ define "title" -}} + {{( pgettext "Quotations" "title" )}} +{{- end }} + +{{ define "breadcrumbs" -}} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.QuotesIndexPage*/ -}} + +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.QuotesIndexPage*/ -}} +
+ {{ with .Filters }} + {{ template "select-field" .Customer }} + {{ template "select-field" .QuoteStatus }} + {{ template "input-field" .FromDate }} + {{ template "input-field" .ToDate }} + {{ template "input-field" .QuoteNumber }} + {{ template "tags-field" .Tags | addTagsAttr (print `data-conditions="` .TagsCondition.Name `-field"`) }} + {{ template "toggle-field" .TagsCondition }} + {{ end }} + +
+ + + + + + + + + + + + + + + + {{ with .Quotes }} + {{- range $quote := . }} + + {{ $title := .Number | printf (pgettext "Select quotation %v" "action") }} + + + + + + + + + + + {{- end }} + {{ else }} + + + + {{ end }} + +
{{( pgettext "All" "quote" )}}{{( pgettext "Date" "title" )}}{{( pgettext "Quotation Num." "title" )}}{{( pgettext "Customer" "title" )}}{{( pgettext "Status" "title" )}}{{( pgettext "Tags" "title" )}}{{( pgettext "Amount" "title" )}}{{( pgettext "Download" "title" )}}{{( pgettext "Actions" "title" )}}
{{ .Date|formatDate }}{{ .Number }}{{ .CustomerName }} + + + {{- range $index, $tag := .Tags }} + {{- if gt $index 0 }}, {{ end -}} + {{ . }} + {{- end }} + {{ .Total|formatPrice }} + +
{{( gettext "No quotations added yet." )}}
+{{- end }} diff --git a/web/template/quotes/new.gohtml b/web/template/quotes/new.gohtml new file mode 100644 index 0000000..e96a2b5 --- /dev/null +++ b/web/template/quotes/new.gohtml @@ -0,0 +1,101 @@ +{{ define "title" -}} + {{( pgettext "New Quotation" "title" )}} +{{- end }} + +{{ define "breadcrumbs" -}} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuotePage*/ -}} + +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuotePage*/ -}} +
+

{{(pgettext "New Quotation" "title")}}

+
+ {{ csrfToken }} + + {{ with .Form -}} + {{ if .RemovedProduct -}} +
+ {{ with .RemovedProduct -}} +

{{printf (gettext "Product “%s” removed") .Name}}

+ + {{ template "hidden-field" .QuoteProductId }} + {{ template "hidden-field" .ProductId }} + {{ template "hidden-field" .Name }} + {{ template "hidden-field" .Price }} + {{ template "hidden-field" .Quantity }} + {{ template "hidden-field" .Discount }} + {{ template "hidden-field" .Description }} + {{ template "hidden-select-field" .Tax }} + {{- end }} +
+ {{- end }} + +
+ {{ template "hidden-select-field" .QuoteStatus }} + {{ template "select-field" .Customer }} + {{ template "input-field" .Date }} + {{ template "tags-field" .Tags }} + {{ template "select-field" .PaymentMethod }} + {{ template "input-field" .TermsAndConditions }} + {{ template "input-field" .Notes }} +
+ {{- range $product := .Products }} + {{ template "quote-product-form" . }} + {{- end }} + {{- end }} + + + + + + + + {{- range $tax := .Taxes }} + + + + + {{- end }} + + + + + +
{{(pgettext "Subtotal" "title")}}{{ .Subtotal | formatPrice }}
{{ index . 0 }}{{ index . 1 | formatPrice }}
{{(pgettext "Total" "title")}}{{ .Total | formatPrice }}
+ +
+ + + +
+
+
+ + +{{- end }} diff --git a/web/template/quotes/product-form.gohtml b/web/template/quotes/product-form.gohtml new file mode 100644 index 0000000..42bbbf4 --- /dev/null +++ b/web/template/quotes/product-form.gohtml @@ -0,0 +1,4 @@ +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.quoteProductForm*/ -}} + {{ template "quote-product-form" . }} +{{- end }} diff --git a/web/template/quotes/products.gohtml b/web/template/quotes/products.gohtml new file mode 100644 index 0000000..1d5b221 --- /dev/null +++ b/web/template/quotes/products.gohtml @@ -0,0 +1,76 @@ +{{ define "title" -}} + {{( pgettext "Add Products to Quotation" "title" )}} +{{- end }} + +{{ define "breadcrumbs" -}} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuoteProductsPage*/ -}} + +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuotationProductsPage*/ -}} +
+

{{(pgettext "Add Products to Quotation" "title")}}

+
+ {{ csrfToken }} + + {{- with .Form }} + {{ template "hidden-select-field" .Customer }} + {{ template "hidden-field" .Date }} + {{ template "hidden-field" .Notes }} + {{ template "hidden-field" .Tags }} + + {{- range $product := .Products }} + {{ template "hidden-field" .QuoteProductId }} + {{ 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 }} + {{- end }} + {{- end }} + + + + + + + + + + + {{ with .Products }} + {{- range . }} + + + + + + {{- end }} + {{ else }} + + + + {{ end }} + +
{{( pgettext "All" "product" )}}{{( pgettext "Name" "title" )}}{{( pgettext "Price" "title" )}}
{{ .Price | formatPrice }}
{{( gettext "No products added yet." )}}
+ +
+ +
+
+
+{{- end }} diff --git a/web/template/quotes/view.gohtml b/web/template/quotes/view.gohtml new file mode 100644 index 0000000..fb59965 --- /dev/null +++ b/web/template/quotes/view.gohtml @@ -0,0 +1,126 @@ +{{ define "title" -}} + {{ .Number | printf ( pgettext "Quotation %s" "title" )}} +{{- end }} + +{{ define "breadcrumbs" -}} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.quote*/ -}} + +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.quote*/ -}} + +
+
+
+

{{ .Number | printf ( pgettext "Quotation %s" "title" )}}

+

{{( pgettext "Date" "title" )}} {{ .Date | formatDate }}

+
+ +
+ {{ .Quoter.Name }}
+ {{ .Quoter.VATIN }}
+ {{ .Quoter.Address }}
+ {{ .Quoter.City }} ({{ .Quoter.PostalCode}}), {{ .Quoter.Province }}
+ {{ .Quoter.Email }}
+ {{ .Quoter.Phone }}
+
+ + +
+ +
+
+ {{ .Quotee.Name }}
+ {{ .Quotee.VATIN }}
+ {{ .Quotee.Address }}
+ {{ .Quotee.City }} ({{ .Quotee.PostalCode}}), {{ .Quotee.Province }}
+
+ + {{- $columns := 5 | add (len .TaxClasses) | add (boolToInt .HasDiscounts) -}} + + + + + + {{ if .HasDiscounts -}} + + {{ end -}} + + + {{ range $class := .TaxClasses -}} + + {{ end -}} + + + + {{ $lastIndex := len .Products | sub 1 }} + {{ range $index, $product := .Products -}} + + {{- if .Description }} + + + + {{ end -}} + + {{- if .Description }} + + {{- else }} + + {{- end -}} + + {{ if $.HasDiscounts -}} + + {{ end -}} + + + {{ range $class := $.TaxClasses -}} + + {{ end -}} + + + {{ if (eq $index $lastIndex) }} + + + + + {{ range $tax := $.Taxes -}} + + + + + {{- end }} + + + + + {{ end }} + + {{- end }} +
{{( pgettext "Concept" "title" )}}{{( pgettext "Price" "title" )}}{{( pgettext "Discount" "title" )}}{{( pgettext "Units" "title" )}}{{( pgettext "Subtotal" "title" )}}{{ . }}{{( pgettext "Total" "title" )}}
{{ .Name }}
{{ .Description }}{{ .Name }}{{ .Price | formatPrice }}{{ $product.Discount | formatPercent }}{{ .Quantity }}{{ .Subtotal | formatPrice }}{{ index $product.Taxes $class | formatPercent }}{{ .Total | formatPrice }}
{{( pgettext "Tax Base" "title" )}}{{ $.Subtotal | formatPrice }}
{{ index . 0 }}{{ index . 1 | formatPrice }}
{{( pgettext "Total" "title" )}}{{ $.Total | formatPrice }}
+ + {{ if .Notes -}} +

{{ .Notes }}

+ {{- end }} +

{{ .PaymentInstructions }}

+ +
+
+{{- end}}