Compare commits

..

8 Commits

Author SHA1 Message Date
jordi fita mas 9931796744 Add HTTP controller and view to add quotes
It still does not support quotes without contact or payment.
2023-06-07 16:35:31 +02:00
jordi fita mas efbb4da07f Added SQL views to compute computations amounts and edit them 2023-06-07 15:31:20 +02:00
jordi fita mas f54681de93 Require invoice_product_product for edit_invoice 2023-06-07 15:09:35 +02:00
jordi fita mas 86bf8765fc Use the correct integer literal for invoice_amount
PostgreSQL actually already casts the strings to integers, but best if
everything is as it should.
2023-06-07 14:54:29 +02:00
jordi fita mas a066726c2e Add function to create new quotes
I had to add the quote_number_format to company, similar to how we do
with invoices.
2023-06-07 14:14:48 +02:00
jordi fita mas aeca90256c Remove setting custom number invoice format from add_expense test
It is not necessary for this test and, since the column already has a
default value, setting it there seems like it might have any
consequence.
2023-06-07 13:27:49 +02:00
jordi fita mas 0e20eab46a Add test for invoice_number_counter counter_zero_or_positive constraint 2023-06-07 13:19:06 +02:00
jordi fita mas 775cdef097 Add foreign key constraint to invoice_number_counter.company_id 2023-06-07 13:11:29 +02:00
69 changed files with 4173 additions and 339 deletions

89
deploy/add_quote.sql Normal file
View File

@ -0,0 +1,89 @@
-- Deploy numerus:add_quote to pg
-- requires: roles
-- requires: schema_numerus
-- requires: quote
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: new_quote_product
-- requires: tax
-- requires: quote_product
-- requires: quote_payment_method
-- requires: quote_contact
-- requires: quote_product_product
-- requires: quote_product_tax
-- requires: next_quote_number
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function add_quote(company integer, quote_date date, contact_id integer, terms_and_conditions text, notes text, payment_method_id integer, tags tag_name[], products new_quote_product[]) returns uuid as
$$
declare
qid integer;
qslug uuid;
product new_quote_product;
ccode text;
qpid integer;
begin
insert into quote (company_id, quote_number, quote_date, terms_and_conditions, notes, tags, currency_code)
select company_id
, next_quote_number(add_quote.company, quote_date)
, quote_date
, terms_and_conditions
, notes
, tags
, currency_code
from company
where company.company_id = add_quote.company
returning quote_id, slug, currency_code
into qid, qslug, ccode;
if contact_id is not null then
insert into quote_contact (quote_id, contact_id)
values (qid, contact_id);
end if;
if payment_method_id is not null then
insert into quote_payment_method (quote_id, payment_method_id)
values (qid, payment_method_id);
end if;
foreach product in array products
loop
insert into quote_product (quote_id, name, description, price, quantity, discount_rate)
select qid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning quote_product_id
into qpid;
if product.product_id is not null then
insert into quote_product_product (quote_product_id, product_id)
values (qpid, product.product_id);
end if;
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
select qpid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
return qslug;
end;
$$
language plpgsql
;
revoke execute on function add_quote(integer, date, integer, text, text, integer, tag_name[], new_quote_product[]) from public;
grant execute on function add_quote(integer, date, integer, text, text, integer, tag_name[], new_quote_product[]) to invoicer;
grant execute on function add_quote(integer, date, integer, text, text, integer, tag_name[], new_quote_product[]) to admin;
commit;

View File

@ -27,6 +27,7 @@ create table company (
country_code country_code not null references country,
currency_code currency_code not null references currency,
invoice_number_format text not null default '"FRA"YYYY0000',
quote_number_format text not null default '"PRE"YYYY0000',
legal_disclaimer text not null default '',
created_at timestamptz not null default current_timestamp
);

View File

@ -0,0 +1,64 @@
-- Deploy numerus:compute_new_quote_amount to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
-- requires: tax
-- requires: new_quote_product
-- requires: new_quote_amount
begin;
set search_path to numerus, public;
create or replace function compute_new_quote_amount(company_id integer, products new_quote_product[]) returns new_quote_amount as
$$
declare
result new_quote_amount;
begin
if array_length(products, 1) is null then
select to_price(0, decimal_digits), array[]::text[][], to_price(0, decimal_digits)
from company
join currency using (currency_code)
where company.company_id = compute_new_quote_amount.company_id
into result.subtotal, result.taxes, result.total;
else
with product as (
select round(parse_price(price, currency.decimal_digits) * quantity * (1 - discount_rate))::integer as subtotal
, tax
, decimal_digits
from unnest(products)
join company on company.company_id = compute_new_quote_amount.company_id
join currency using (currency_code)
)
, tax_amount as (
select tax_id
, sum(round(subtotal * tax.rate)::integer)::integer as amount
, decimal_digits
from product, unnest(product.tax) as product_tax(tax_id)
join tax using (tax_id)
group by tax_id, decimal_digits
)
, tax_total as (
select sum(amount)::integer as amount, array_agg(array[name, to_price(amount, decimal_digits)]) as taxes
from tax_amount
join tax using (tax_id)
)
select to_price(sum(subtotal)::integer, decimal_digits)
, coalesce(taxes, array[]::text[][])
, to_price(sum(subtotal)::integer + coalesce(tax_total.amount, 0), decimal_digits) as total
from product, tax_total
group by tax_total.amount, taxes, decimal_digits
into result.subtotal, result.taxes, result.total;
end if;
return result;
end
$$
language plpgsql
stable;
revoke execute on function compute_new_quote_amount(integer, new_quote_product[]) from public;
grant execute on function compute_new_quote_amount(integer, new_quote_product[]) to invoicer;
grant execute on function compute_new_quote_amount(integer, new_quote_product[]) to admin;
commit;

View File

@ -6,6 +6,7 @@
-- requires: edited_invoice_product
-- requires: tax
-- requires: invoice_product
-- requires: invoice_product_product
-- requires: invoice_product_tax
-- requires: tag_name

132
deploy/edit_quote.sql Normal file
View File

@ -0,0 +1,132 @@
-- Deploy numerus:edit_quote to pg
-- requires: roles
-- requires: schema_numerus
-- requires: quote
-- requires: currency
-- requires: parse_price
-- requires: edited_quote_product
-- requires: tax
-- requires: quote_contact
-- requires: quote_payment_method
-- requires: quote_product
-- requires: quote_product_tax
-- requires: quote_product_product
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function edit_quote(quote_slug uuid, quote_status text, contact_id integer, terms_and_conditions text, notes text, payment_method_id integer, tags tag_name[], products edited_quote_product[]) returns uuid as
$$
declare
qid integer;
products_to_keep integer[];
products_to_delete integer[];
company integer;
ccode text;
product edited_quote_product;
qpid integer;
begin
update quote
set quote_status = edit_quote.quote_status
, terms_and_conditions = edit_quote.terms_and_conditions
, notes = edit_quote.notes
-- contact_id = edit_quote.contact_id
--, payment_method_id = edit_quote.payment_method_id
, tags = edit_quote.tags
where slug = quote_slug
returning quote_id, company_id, currency_code
into qid, company, ccode
;
if qid is null then
return null;
end if;
if payment_method_id is null then
delete from quote_payment_method where quote_id = qid;
else
insert into quote_payment_method (quote_id, payment_method_id)
values (qid, payment_method_id)
on conflict (quote_id) do update
set payment_method_id = edit_quote.payment_method_id;
end if;
if contact_id is null then
delete from quote_contact where quote_id = qid;
else
insert into quote_contact (quote_id, contact_id)
values (qid, contact_id)
on conflict (quote_id) do update
set contact_id = edit_quote.contact_id;
end if;
foreach product in array products
loop
if product.quote_product_id is null then
insert into quote_product (quote_id, name, description, price, quantity, discount_rate)
select qid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning quote_product_id
into qpid;
else
qpid := product.quote_product_id;
update quote_product
set name = product.name
, description = coalesce(product.description, '')
, price = parse_price(product.price, currency.decimal_digits)
, quantity = product.quantity
, discount_rate = product.discount_rate
from currency
where quote_product_id = qpid
and currency_code = ccode;
end if;
products_to_keep := array_append(products_to_keep, qpid);
if product.product_id is null then
delete from quote_product_product where quote_product_id = qpid;
else
insert into quote_product_product (quote_product_id, product_id)
values (qpid, product.product_id)
on conflict (quote_product_id) do update
set product_id = product.product_id;
end if;
delete from quote_product_tax where quote_product_id = qpid;
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
select qpid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
select array_agg(quote_product_id)
into products_to_delete
from quote_product
where quote_id = qid
and not (quote_product_id = any(products_to_keep));
if array_length(products_to_delete, 1) > 0 then
delete from quote_product_tax where quote_product_id = any(products_to_delete);
delete from quote_product_product where quote_product_id = any(products_to_delete);
delete from quote_product where quote_product_id = any(products_to_delete);
end if;
return quote_slug;
end;
$$
language plpgsql;
revoke execute on function edit_quote(uuid, text, integer, text, text, integer, tag_name[], edited_quote_product[]) from public;
grant execute on function edit_quote(uuid, text, integer, text, text, integer, tag_name[], edited_quote_product[]) to invoicer;
grant execute on function edit_quote(uuid, text, integer, text, text, integer, tag_name[], edited_quote_product[]) to admin;
commit;

View File

@ -0,0 +1,20 @@
-- Deploy numerus:edited_quote_product to pg
-- requires: schema_numerus
-- requires: discount_rate
begin;
set search_path to numerus, public;
create type edited_quote_product as
( quote_product_id integer
, product_id integer
, name text
, description text
, price text
, quantity integer
, discount_rate discount_rate
, tax integer[]
);
commit;

View File

@ -7,7 +7,7 @@ begin;
set search_path to numerus, public;
create table invoice_number_counter (
company_id integer not null,
company_id integer not null references company,
year integer not null constraint year_always_positive check(year > 0),
currval integer not null constraint counter_zero_or_positive check(currval >= 0),
primary key (company_id, year)

View File

@ -0,0 +1,14 @@
-- Deploy numerus:new_quote_amount to pg
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create type new_quote_amount as (
subtotal text,
taxes text[][],
total text
);
commit;

View File

@ -0,0 +1,19 @@
-- Deploy numerus:new_quote_product to pg
-- requires: schema_numerus
-- requires: discount_rate
begin;
set search_path to numerus, public;
create type new_quote_product as (
product_id integer,
name text,
description text,
price text,
quantity integer,
discount_rate discount_rate,
tax integer[]
);
commit;

View File

@ -0,0 +1,40 @@
-- Deploy numerus:next_quote_number to pg
-- requires: roles
-- requires: schema_numerus
-- requires: quote_number_counter
begin;
set search_path to numerus, public;
create or replace function next_quote_number(company integer, quote_date date) returns text
as
$$
declare
num integer;
quote_number text;
begin
insert into quote_number_counter (company_id, year, currval)
values (next_quote_number.company, date_part('year', quote_date), 1)
on conflict (company_id, year) do
update
set currval = quote_number_counter.currval + 1
returning currval
into num;
select to_char(quote_date, to_char(num, 'FM' || replace(quote_number_format, '"', '\""')))
into quote_number
from company
where company_id = next_quote_number.company;
return quote_number;
end;
$$
language plpgsql
;
revoke execute on function next_quote_number(integer, date) from public;
grant execute on function next_quote_number(integer, date) to invoicer;
grant execute on function next_quote_number(integer, date) to admin;
commit;

23
deploy/quote_amount.sql Normal file
View File

@ -0,0 +1,23 @@
-- Deploy numerus:quote_amount to pg
-- requires: roles
-- requires: schema_numerus
-- requires: quote_product
-- requires: quote_product_amount
begin;
set search_path to numerus, public;
create or replace view quote_amount as
select quote_id
, sum(subtotal)::integer as subtotal
, sum(total)::integer as total
from quote_product
join quote_product_amount using (quote_product_id)
group by quote_id
;
grant select on table quote_amount to invoicer;
grant select on table quote_amount to admin;
commit;

View File

@ -0,0 +1,33 @@
-- Deploy numerus:quote_number_counter to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
begin;
set search_path to numerus, public;
create table quote_number_counter (
company_id integer not null references company,
year integer not null constraint year_always_positive check(year > 0),
currval integer not null constraint counter_zero_or_positive check(currval >= 0),
primary key (company_id, year)
);
grant select, insert, update on table quote_number_counter to invoicer;
grant select, insert, update on table quote_number_counter to admin;
alter table quote_number_counter enable row level security;
create policy company_policy
on quote_number_counter
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = quote_number_counter.company_id
)
);
commit;

View File

@ -0,0 +1,23 @@
-- Deploy numerus:quote_product_amount to pg
-- requires: roles
-- requires: schema_numerus
-- requires: quote_product
-- requires: quote_product_tax
begin;
set search_path to numerus, public;
create or replace view quote_product_amount as
select quote_product_id
, round(price * quantity * (1 - discount_rate))::integer as subtotal
, max(round(price * quantity * (1 - discount_rate))::integer) + coalesce(sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer, 0) as total
from quote_product
left join quote_product_tax using (quote_product_id)
group by quote_product_id, price, quantity, discount_rate
;
grant select on table quote_product_amount to invoicer;
grant select on table quote_product_amount to admin;
commit;

View File

@ -0,0 +1,24 @@
-- Deploy numerus:quote_tax_amount to pg
-- requires: roles
-- requires: schema_numerus
-- requires: quote_product
-- requires: quote_product_tax
begin;
set search_path to numerus, public;
create or replace view quote_tax_amount as
select quote_id
, tax_id
, sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer as amount
from quote_product
join quote_product_tax using (quote_product_id)
group by quote_id
, tax_id
;
grant select on table quote_tax_amount to invoicer;
grant select on table quote_tax_amount to admin;
commit;

View File

@ -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)

View File

@ -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
}

1102
pkg/quote.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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)

466
po/ca.po
View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-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 <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\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 "Sha 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 "Nesteu 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 dusuari 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 dimpost"
#: pkg/company.go:306
#: pkg/company.go:359
msgid "Select a tax class"
msgstr "Escolliu una classe dimpost"
#: 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 limpost en blanc."
#: pkg/company.go:334
#: pkg/company.go:387
msgid "Selected tax class is not valid."
msgstr "Heu seleccionat una classe dimpost 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 dacceptació"
#: 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 "LID 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 "LID 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 limport en blanc."
msgid "Amount must be a number greater than zero."
msgstr "Limport 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 "LID 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 "LID 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"

470
po/es.po
View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-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 <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\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"

7
revert/add_quote.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:add_quote from pg
begin;
drop function if exists numerus.add_quote(integer, date, integer, text, text, integer, numerus.tag_name[], numerus.new_quote_product[]);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:compute_new_quote_amount from pg
begin;
drop function if exists numerus.compute_new_quote_amount(integer, numerus.new_quote_product[]);
commit;

7
revert/edit_quote.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:edit_quote from pg
begin;
drop function if exists numerus.edit_quote(uuid, text, integer, text, text, integer, numerus.tag_name[], numerus.edited_quote_product[]);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:edited_quote_product from pg
begin;
drop type if exists numerus.edited_quote_product;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:new_quote_amount from pg
begin;
drop type if exists numerus.new_quote_amount;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:new_quote_product from pg
begin;
drop type if exists numerus.new_quote_product;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:next_quote_number from pg
begin;
drop function if exists numerus.next_quote_number(integer, date);
commit;

7
revert/quote_amount.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:quote_amount from pg
begin;
drop view if exists numerus.quote_amount;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:quote_number_counter from pg
begin;
drop table if exists numerus.quote_number_counter;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:quote_product_amount from pg
begin;
drop view if exists numerus.quote_product_amount;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:quote_tax_amount from pg
begin;
drop view if exists numerus.quote_tax_amount;
commit;

View File

@ -67,7 +67,7 @@ invoice_amount [schema_numerus invoice_product invoice_product_amount] 2023-02-2
new_invoice_amount [schema_numerus] 2023-02-23T12:08:25Z jordi fita mas <jordi@tandem.blog> # Add type to return when computing new invoice amounts
compute_new_invoice_amount [schema_numerus company currency tax new_invoice_product new_invoice_amount] 2023-02-23T12:20:13Z jordi fita mas <jordi@tandem.blog> # Add function to compute the subtotal, taxes, and total amounts for a new invoice
edited_invoice_product [schema_numerus discount_rate] 2023-03-11T19:22:24Z jordi fita mas <jordi@tandem.blog> # Add typo for passing products to edited invoices
edit_invoice [schema_numerus invoice currency parse_price edited_invoice_product tax invoice_product invoice_product_tax tag_name] 2023-03-11T18:30:50Z jordi fita mas <jordi@tandem.blog> # Add function to edit invoices
edit_invoice [schema_numerus invoice currency parse_price edited_invoice_product tax invoice_product invoice_product_product invoice_product_tax tag_name] 2023-03-11T18:30:50Z jordi fita mas <jordi@tandem.blog> # Add function to edit invoices
add_contact [schema_numerus extension_vat email extension_pg_libphonenumber extension_uri country_code tag_name contact] 2023-03-25T22:32:37Z jordi fita mas <jordi@tandem.blog> # Add function to create new contacts
edit_contact [schema_numerus email extension_uri country_code tag_name contact extension_vat extension_pg_libphonenumber] 2023-03-25T23:20:27Z jordi fita mas <jordi@tandem.blog> # Add function to edit contacts
expense [schema_numerus contact company currency_code currency tag_name] 2023-04-30T13:46:36Z jordi fita mas <jordi@tandem.blog> # Add the expense relation
@ -86,3 +86,14 @@ quote_product [roles schema_numerus quote discount_rate] 2023-06-06T18:25:05Z jo
quote_product_product [roles schema_numerus quote_product product] 2023-06-06T18:38:26Z jordi fita mas <jordi@tandem.blog> # Add relation of quote products and registered products
quote_product_tax [roles schema_numerus quote_product tax tax_rate] 2023-06-06T18:46:33Z jordi fita mas <jordi@tandem.blog> # Add relation of quotation product tax
quote_payment_method [roles schema_numerus quote payment_method] 2023-06-06T18:59:12Z jordi fita mas <jordi@tandem.blog> # Add relation for the payment method of quotes
quote_number_counter [roles schema_numerus company] 2023-06-07T11:05:51Z jordi fita mas <jordi@tandem.blog> # Add relatin to keep a counter of quote numbers
next_quote_number [roles schema_numerus quote_number_counter] 2023-06-07T11:20:54Z jordi fita mas <jordi@tandem.blog> # Add function to retrieve the next quote number
new_quote_product [schema_numerus discount_rate] 2023-06-07T11:36:37Z jordi fita mas <jordi@tandem.blog> # Add type for passing products to new quotes
add_quote [roles schema_numerus quote company currency parse_price new_quote_product tax quote_product quote_payment_method quote_contact quote_product_product quote_product_tax next_quote_number tag_name] 2023-06-07T11:39:45Z jordi fita mas <jordi@tandem.blog> # Add function to create new quotes
quote_tax_amount [roles schema_numerus quote_product quote_product_tax] 2023-06-07T12:45:17Z jordi fita mas <jordi@tandem.blog> # Add add view for quote tax amount
quote_product_amount [roles schema_numerus quote_product quote_product_tax] 2023-06-07T12:48:58Z jordi fita mas <jordi@tandem.blog> # Add view for quote product subtotal and total
quote_amount [roles schema_numerus quote_product quote_product_amount] 2023-06-07T12:52:51Z jordi fita mas <jordi@tandem.blog> # Add view to compute subtotal and total for quotes
new_quote_amount [schema_numerus] 2023-06-07T12:57:45Z jordi fita mas <jordi@tandem.blog> # Add type to return when computing new quote amounts
compute_new_quote_amount [roles schema_numerus company tax new_quote_product new_quote_amount] 2023-06-07T13:00:07Z jordi fita mas <jordi@tandem.blog> # Add function to compute the subtotal, taxes, and total amounts for a new quotation
edited_quote_product [schema_numerus discount_rate] 2023-06-07T13:03:23Z jordi fita mas <jordi@tandem.blog> # Add type for passing products to edit quotations
edit_quote [roles schema_numerus quote currency parse_price edited_quote_product tax quote_contact quote_payment_method quote_product quote_product_tax quote_product_product tag_name] 2023-06-07T13:08:10Z jordi fita mas <jordi@tandem.blog> # Add function to edit quotations

View File

@ -31,9 +31,9 @@ reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, invoice_number_format, default_payment_method_id)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', '"F"YYYY0000', 111)
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', '"INV"000-YY', 222)
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111)
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 222)
;
insert into payment_method (payment_method_id, company_id, name, instructions)

161
test/add_quote.sql Normal file
View File

@ -0,0 +1,161 @@
-- Test add_quote
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(18);
set search_path to auth, numerus, public;
select has_function('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]']);
select function_lang_is('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]'], 'plpgsql');
select function_returns('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]'], 'uuid');
select isnt_definer('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]']);
select volatility_is('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]'], 'volatile');
select function_privs_are('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'add_quote', array ['integer', 'date', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'new_quote_product[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate quote_number_counter cascade;
truncate quote_product_tax cascade;
truncate quote_product cascade;
truncate quote_contact cascade;
truncate quote_payment_method cascade;
truncate quote cascade;
truncate contact cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate payment_method cascade;
truncate company cascade;
reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, quote_number_format, default_payment_method_id)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', '"Q"YYYY0000', 111)
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', '"QUO"000-YY', 222)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
, (222, 2, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into quote_number_counter (company_id, year, currval)
values (1, 2023, '5')
, (2, 2023, '55')
;
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
, (22, 2, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (3, 1, 11, 'IRPF -15 %', -0.15)
, (4, 1, 11, 'IVA 21 %', 0.21)
, (5, 2, 22, 'IRPF -7 %', -0.07)
, (6, 2, 22, 'IVA 10 %', 0.10)
;
insert into product (product_id, company_id, name, price)
values ( 7, 1, 'Product 2.1', 1212)
, ( 8, 1, 'Product 2.2', 2424)
, ( 9, 2, 'Product 4.1', 4848)
, (10, 2, 'Product 4.2', 9696)
, (11, 2, 'Product 4.3', 1010)
;
insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (12, 1, 'Contact 2.1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
, (13, 1, 'Contact 2.2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES')
, (14, 2, 'Contact 4.1', 'XX777', '', '999-999-999', 'e@e', '', '', '', '', '', 'ES')
, (15, 2, 'Contact 4.2', 'XX888', '', '000-000-000', 'f@f', '', '', '', '', '', 'ES')
;
select lives_ok(
$$ select add_quote(1, '2023-02-15', 12, 'No need for advance payment', 'Notes 1', null, '{tag1,tag2}','{"(7,Product 1,Description 1,12.24,2,0.0,{4})"}') $$,
'Should be able to insert an quote for the first company with a product'
);
select lives_ok(
$$ select add_quote(1, '2023-02-16', null, 'Pay 10% in advance', 'Notes 2', 111, '{}', '{"(7,Product 1 bis,Description 1 bis,33.33,1,0.50,\"{4,3}\")","(8,Product 2,Description 2,24.00,3,0.75,{})"}') $$,
'Should be able to insert a second quote for the first company with two product'
);
select lives_ok(
$$ select add_quote(2, '2023-02-14', 15, 'Pay half in advance', 'Notes 3', 222, '{tag3}','{"(11,Product 4.3,,11.11,1,0.0,{6})","(,Product 4.4,Description 4.4,22.22,3,0.05,{})"}') $$,
'Should be able to insert an quote for the second company with a product'
);
select bag_eq(
$$ select company_id, quote_number, quote_date, quote_status, terms_and_conditions, notes, currency_code, tags, created_at from quote $$,
$$ values (1, 'Q20230006', '2023-02-15'::date, 'created', 'No need for advance payment', 'Notes 1', 'EUR', '{tag1,tag2}'::tag_name[], current_timestamp)
, (1, 'Q20230007', '2023-02-16'::date, 'created', 'Pay 10% in advance', 'Notes 2', 'EUR', '{}'::tag_name[], current_timestamp)
, (2, 'QUO056-23', '2023-02-14'::date, 'created', 'Pay half in advance', 'Notes 3', 'USD', '{tag3}'::tag_name[], current_timestamp)
$$,
'Should have created all quotes'
);
select bag_eq(
$$ select quote_number, payment_method_id from quote_payment_method join quote using (quote_id) $$,
$$ values ('Q20230007', 111)
, ('QUO056-23', 222)
$$,
'Should have created all payment methods'
);
select bag_eq(
$$ select quote_number, contact_id from quote_contact join quote using (quote_id) $$,
$$ values ('Q20230006', 12)
, ('QUO056-23', 15)
$$,
'Should have created all contacts'
);
select bag_eq(
$$ select quote_number, name, description, price, quantity, discount_rate from quote_product join quote using (quote_id) $$,
$$ values ('Q20230006', 'Product 1', 'Description 1', 1224, 2, 0.00)
, ('Q20230007', 'Product 1 bis', 'Description 1 bis', 3333, 1, 0.50)
, ('Q20230007', 'Product 2', 'Description 2', 2400, 3, 0.75)
, ('QUO056-23', 'Product 4.3', '', 1111, 1, 0.0)
, ('QUO056-23', 'Product 4.4', 'Description 4.4', 2222, 3, 0.05)
$$,
'Should have created all quote products'
);
select bag_eq(
$$ select quote_number, product_id, name from quote_product left join quote_product_product using (quote_product_id) join quote using (quote_id) $$,
$$ values ('Q20230006', 7, 'Product 1')
, ('Q20230007', 7, 'Product 1 bis')
, ('Q20230007', 8, 'Product 2')
, ('QUO056-23', 11, 'Product 4.3')
, ('QUO056-23', NULL, 'Product 4.4')
$$,
'Should have linked all quote products'
);
select bag_eq(
$$ select quote_number, name, tax_id, tax_rate from quote_product_tax join quote_product using (quote_product_id) join quote using (quote_id) $$,
$$ values ('Q20230006', 'Product 1', 4, 0.21)
, ('Q20230007', 'Product 1 bis', 4, 0.21)
, ('Q20230007', 'Product 1 bis', 3, -0.15)
, ('QUO056-23', 'Product 4.3', 6, 0.10)
$$,
'Should have created all quote product taxes'
);
select *
from finish();
rollback;

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin;
select plan(96);
select plan(101);
set search_path to numerus, auth, public;
@ -100,6 +100,12 @@ select col_not_null('company', 'invoice_number_format');
select col_has_default('company', 'invoice_number_format');
select col_default_is('company', 'invoice_number_format', '"FRA"YYYY0000');
select has_column('company', 'quote_number_format');
select col_type_is('company', 'quote_number_format', 'text');
select col_not_null('company', 'quote_number_format');
select col_has_default('company', 'quote_number_format');
select col_default_is('company', 'quote_number_format', '"PRE"YYYY0000');
select has_column('company', 'legal_disclaimer');
select col_type_is('company', 'legal_disclaimer', 'text');
select col_not_null('company', 'legal_disclaimer');

View File

@ -0,0 +1,80 @@
-- Test compute_new_quote_amount
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(14);
set search_path to numerus, auth, public;
select has_function('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]']);
select function_lang_is('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]'], 'plpgsql');
select function_returns('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]'], 'new_quote_amount');
select isnt_definer('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]']);
select volatility_is('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]'], 'stable');
select function_privs_are('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'compute_new_quote_amount', array ['integer', 'new_quote_product[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate tax cascade;
truncate tax_class cascade;
truncate payment_method cascade;
truncate company cascade;
reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id)
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
select is(
compute_new_quote_amount(1, '{}'),
'(0.00,"{}",0.00)'::new_quote_amount
);
select is(
compute_new_quote_amount(1, '{"(6,P,D,1.00,1,0.0,\"{2,5}\")","(6,P,D,2.00,2,0.1,{3})"}'),
'(4.60,"{{IRPF -15 %,-0.15},{IVA 4 %,0.14},{IVA 21 %,0.21}}",4.80)'::new_quote_amount
);
select is(
compute_new_quote_amount(1, '{"(6,P,D,2.22,3,0.0,\"{2,4,5}\")","(6,P,D,3.33,4,0.2,{4})"}'),
'(17.32,"{{IRPF -15 %,-1.00},{IVA 10 %,1.74},{IVA 21 %,1.40}}",19.46)'::new_quote_amount
);
select is(
compute_new_quote_amount(1, '{"(6,P,D,4.44,5,0.0,\"{4,5}\")","(6,P,D,5.55,6,0.1,\"{5,3}\")"}'),
'(52.17,"{{IVA 4 %,1.20},{IVA 10 %,2.22},{IVA 21 %,10.95}}",66.54)'::new_quote_amount
);
select is(
compute_new_quote_amount(1, '{"(6,P,D,7.77,8,0.0,\"{}\")"}'),
'(62.16,"{}",62.16)'::new_quote_amount
);
select *
from finish();
rollback;

173
test/edit_quote.sql Normal file
View File

@ -0,0 +1,173 @@
-- Test edit_quote
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(16);
set search_path to auth, numerus, public;
select has_function('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]']);
select function_lang_is('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]'], 'plpgsql');
select function_returns('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]'], 'uuid');
select isnt_definer('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]']);
select volatility_is('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]'], 'volatile');
select function_privs_are('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_quote', array ['uuid', 'text', 'integer', 'text', 'text', 'integer', 'tag_name[]', 'edited_quote_product[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate quote_product_tax cascade;
truncate quote_product cascade;
truncate quote_payment_method cascade;
truncate quote_contact cascade;
truncate quote cascade;
truncate contact cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate payment_method cascade;
truncate company cascade;
reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
, (112, 1, 'bank', 'send money to my bank account')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (3, 1, 11, 'IRPF -15 %', -0.15)
, (4, 1, 11, 'IVA 21 %', 0.21)
;
insert into product (product_id, company_id, name, price)
values ( 7, 1, 'Product 1.1', 1212)
, ( 8, 1, 'Product 2.2', 2424)
, ( 9, 1, 'Product 3.3', 3636)
;
insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (12, 1, 'Contact 2.1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
, (13, 1, 'Contact 2.2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES')
;
insert into quote (quote_id, company_id, slug, quote_number, quote_date, currency_code, tags)
values (15, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'QUO1', '2023-03-10', 'EUR', '{tag1}')
, (16, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'QUO2', '2023-03-09', 'EUR', '{tag2}')
, (17, 1, '0b899316-f7d0-4175-a9d5-cea855844716', 'QUO3', '2023-03-11', 'EUR', '{}')
;
insert into quote_payment_method (quote_id, payment_method_id)
values (15, 111)
, (16, 111)
;
insert into quote_contact (quote_id, contact_id)
values (15, 12)
, (16, 13)
;
insert into quote_product (quote_product_id, quote_id, name, price)
values (19, 15, 'P1.0', 1100)
, (20, 15, 'P2.0', 2200)
, (21, 16, 'P1.1', 1111)
, (22, 16, 'P2.1', 2211)
, (23, 17, 'P3.1', 3311)
;
insert into quote_product_product (quote_product_id, product_id)
values (19, 7)
, (20, 8)
, (21, 7)
, (22, 8)
;
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
values (19, 4, 0.21)
, (20, 4, 0.21)
, (21, 3, -0.07)
, (21, 4, 0.21)
, (22, 3, -0.15)
;
select lives_ok(
$$ select edit_quote('7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'accepted', null, 'Terms 1', 'Notes 1', 112, array['tag1'], '{"(20,,p1.0,D1,11.01,2,0.50,{4})","(,,p1.3,D3,33.33,3,0.05,{3})"}') $$,
'Should be able to edit the first invoice'
);
select lives_ok(
$$ select edit_quote('b57b980b-247b-4be4-a0b7-03a7819c53ae', 'sent', 12, 'Terms 2', 'Notes 2', null, array['tag1', 'tag3'], '{"(21,7,P1.1,,11.11,1,0.0,{3})","(22,8,p2.1,D2,24.00,3,0.75,\"{3,4}\")","(,9,p3.3,,36.36,2,0.05,{4})"}') $$,
'Should be able to edit the second quote'
);
select lives_ok(
$$ select edit_quote('0b899316-f7d0-4175-a9d5-cea855844716', 'rejected', 13, '', '', 111, '{}', '{"(23,,p3.1,,41.41,1,0.0,{})"}') $$,
'Should be able to edit the third quote'
);
select bag_eq(
$$ select quote_number, quote_date, contact_id, quote_status, terms_and_conditions, notes, tags, payment_method_id from quote left join quote_contact using (quote_id) left join quote_payment_method using (quote_id) $$,
$$ values ('QUO1', '2023-03-10'::date, null, 'accepted', 'Terms 1', 'Notes 1', '{tag1}'::tag_name[], 112)
, ('QUO2', '2023-03-09'::date, 12, 'sent', 'Terms 2', 'Notes 2', '{tag1,tag3}'::tag_name[], null)
, ('QUO3', '2023-03-11'::date, 13, 'rejected', '', '', '{}'::tag_name[], 111)
$$,
'Should have updated all quotes'
);
select bag_eq(
$$ select quote_number, name, description, price, quantity, discount_rate from quote_product join quote using (quote_id) $$,
$$ values ('QUO1', 'p1.0', 'D1', 1101, 2, 0.50)
, ('QUO1', 'p1.3', 'D3', 3333, 3, 0.05)
, ('QUO2', 'P1.1', '', 1111, 1, 0.00)
, ('QUO2', 'p2.1', 'D2', 2400, 3, 0.75)
, ('QUO2', 'p3.3', '', 3636, 2, 0.05)
, ('QUO3', 'p3.1', '', 4141, 1, 0.00)
$$,
'Should have updated all existing quote products, added new ones, and removed the ones not give to the function'
);
select bag_eq(
$$ select quote_number, product_id, name from quote_product left join quote_product_product using (quote_product_id) join quote using (quote_id) $$,
$$ values ('QUO1', NULL, 'p1.0')
, ('QUO1', NULL, 'p1.3')
, ('QUO2', 7, 'P1.1')
, ('QUO2', 8, 'p2.1')
, ('QUO2', 9, 'p3.3')
, ('QUO3', NULL, 'p3.1')
$$,
'Should have updated all existing quote products id, added new ones, and removed the ones not give to the function'
);
select bag_eq(
$$ select quote_number, name, tax_id, tax_rate from quote_product_tax join quote_product using (quote_product_id) join quote using (quote_id) $$,
$$ values ('QUO1', 'p1.0', 4, 0.21)
, ('QUO1', 'p1.3', 3, -0.15)
, ('QUO2', 'P1.1', 3, -0.15)
, ('QUO2', 'p2.1', 3, -0.15)
, ('QUO2', 'p2.1', 4, 0.21)
, ('QUO2', 'p3.3', 4, 0.21)
$$,
'Should have updated all quote product taxes, added new ones, and removed the ones not given to the function'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,26 @@
-- Test edited_quote_product
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(10);
set search_path to numerus, public;
select has_composite('numerus', 'edited_quote_product', 'Composite type numerus.edited_quote_product should exist');
select columns_are('numerus', 'edited_quote_product', array['quote_product_id', 'product_id', 'name', 'description', 'price', 'quantity', 'discount_rate', 'tax']);
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'quote_product_id'::name, 'integer');
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'product_id'::name, 'integer');
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'name'::name, 'text');
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'description'::name, 'text');
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'price'::name, 'text');
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'quantity'::name, 'integer');
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'discount_rate'::name, 'discount_rate');
select col_type_is('numerus'::name, 'edited_quote_product'::name, 'tax'::name, 'integer[]');
select *
from finish();
rollback;

View File

@ -64,10 +64,10 @@ values (7, 1, 'Contact', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '',
;
insert into invoice (invoice_id, company_id, invoice_number, invoice_date, contact_id, currency_code, payment_method_id)
values ( 8, 1, 'I1', current_date, 7, 'EUR', '111')
, ( 9, 1, 'I2', current_date, 7, 'EUR', '111')
, (10, 1, 'I3', current_date, 7, 'EUR', '111')
, (11, 1, 'I4', current_date, 7, 'EUR', '111')
values ( 8, 1, 'I1', current_date, 7, 'EUR', 111)
, ( 9, 1, 'I2', current_date, 7, 'EUR', 111)
, (10, 1, 'I3', current_date, 7, 'EUR', 111)
, (11, 1, 'I4', current_date, 7, 'EUR', 111)
;
insert into invoice_product (invoice_product_id, invoice_id, name, price, quantity, discount_rate)

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin;
select plan(25);
select plan(28);
set search_path to numerus, auth, public;
@ -19,6 +19,8 @@ select table_privs_are('invoice_number_counter', 'admin', array ['SELECT', 'INSE
select table_privs_are('invoice_number_counter', 'authenticator', array []::text[]);
select has_column('invoice_number_counter', 'company_id');
select col_is_fk('invoice_number_counter', 'company_id');
select fk_ok('invoice_number_counter', 'company_id', 'company', 'company_id');
select col_type_is('invoice_number_counter', 'company_id', 'integer');
select col_not_null('invoice_number_counter', 'company_id');
select col_hasnt_default('invoice_number_counter', 'company_id');
@ -66,11 +68,6 @@ values (2, 1)
, (4, 5)
;
insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (6, 2, 'Contact 1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
, (8, 4, 'Contact 2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES')
;
insert into invoice_number_counter (company_id, year, currval)
values (2, 2010, 6)
, (2, 2011, 8)
@ -119,11 +116,19 @@ reset role;
select lives_ok( $$
insert into invoice_number_counter (company_id, year, currval)
values (2, 2008, 0)
values (2, 2009, 0)
$$,
'Should allow starting a counter from zero'
);
select throws_ok( $$
insert into invoice_number_counter (company_id, year, currval)
values (2, 2008, -1)
$$,
'23514', 'new row for relation "invoice_number_counter" violates check constraint "counter_zero_or_positive"',
'Should not allow starting a counter from a negative value'
);
select throws_ok( $$
insert into invoice_number_counter (company_id, year, currval)
values (2, -2008, 1)

21
test/new_quote_amount.sql Normal file
View File

@ -0,0 +1,21 @@
-- Test new_quote_amount
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(5);
set search_path to numerus, public;
select has_composite('numerus', 'new_quote_amount', 'Composite type numerus.new_quote_amount should exist');
select columns_are('numerus', 'new_quote_amount', array['subtotal', 'taxes', 'total']);
select col_type_is('numerus'::name, 'new_quote_amount'::name, 'subtotal'::name, 'text');
select col_type_is('numerus'::name, 'new_quote_amount'::name, 'taxes'::name, 'text[]');
select col_type_is('numerus'::name, 'new_quote_amount'::name, 'total'::name, 'text');
select *
from finish();
rollback;

View File

@ -0,0 +1,25 @@
-- Test new_quote_product
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(9);
set search_path to numerus, public;
select has_composite('numerus', 'new_quote_product', 'Composite type numerus.new_quote_product should exist');
select columns_are('numerus', 'new_quote_product', array['product_id', 'name', 'description', 'price', 'quantity', 'discount_rate', 'tax']);
select col_type_is('numerus'::name, 'new_quote_product'::name, 'product_id'::name, 'integer');
select col_type_is('numerus'::name, 'new_quote_product'::name, 'name'::name, 'text');
select col_type_is('numerus'::name, 'new_quote_product'::name, 'description'::name, 'text');
select col_type_is('numerus'::name, 'new_quote_product'::name, 'price'::name, 'text');
select col_type_is('numerus'::name, 'new_quote_product'::name, 'quantity'::name, 'integer');
select col_type_is('numerus'::name, 'new_quote_product'::name, 'discount_rate'::name, 'discount_rate');
select col_type_is('numerus'::name, 'new_quote_product'::name, 'tax'::name, 'integer[]');
select *
from finish();
rollback;

View File

@ -0,0 +1,60 @@
-- Test next_quote_number
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(17);
set search_path to numerus, public;
select has_function('numerus', 'next_quote_number', array ['integer', 'date']);
select function_lang_is('numerus', 'next_quote_number', array ['integer', 'date'], 'plpgsql');
select function_returns('numerus', 'next_quote_number', array ['integer', 'date'], 'text');
select isnt_definer('numerus', 'next_quote_number', array ['integer', 'date']);
select volatility_is('numerus', 'next_quote_number', array ['integer', 'date'], 'volatile');
select function_privs_are('numerus', 'next_quote_number', array ['integer', 'date'], 'guest', array []::text[]);
select function_privs_are('numerus', 'next_quote_number', array ['integer', 'date'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'next_quote_number', array ['integer', 'date'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'next_quote_number', array ['integer', 'date'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate quote_number_counter cascade;
truncate payment_method cascade;
truncate company cascade;
reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, quote_number_format, default_payment_method_id)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', '"Q"YYYY0000', 111)
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', '"QUO"000-YY', 222)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
, (222, 2, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into quote_number_counter (company_id, year, currval)
values (1, 2010, 5)
, (2, 2010, 6)
;
select is( next_quote_number(1, '2010-12-25'), 'Q20100006' );
select is( next_quote_number(2, '2010-12-25'), 'QUO007-10' );
select is( next_quote_number(1, '2010-10-17'), 'Q20100007' );
select is( next_quote_number(2, '2010-10-17'), 'QUO008-10' );
select is( next_quote_number(1, '2011-12-25'), 'Q20110001' );
select is( next_quote_number(2, '2012-12-25'), 'QUO001-12' );
select is( next_quote_number(1, '2011-12-25'), 'Q20110002' );
select is( next_quote_number(2, '2012-12-25'), 'QUO002-12' );
select *
from finish();
rollback;

110
test/quote_amount.sql Normal file
View File

@ -0,0 +1,110 @@
-- Test quote_amount
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to numerus, auth, public;
select has_view('quote_amount');
select table_privs_are('quote_amount', 'guest', array[]::text[]);
select table_privs_are('quote_amount', 'invoicer', array['SELECT']);
select table_privs_are('quote_amount', 'admin', array['SELECT']);
select table_privs_are('quote_amount', 'authenticator', array[]::text[]);
select has_column('quote_amount', 'quote_id');
select col_type_is('quote_amount', 'quote_id', 'integer');
select has_column('quote_amount', 'subtotal');
select col_type_is('quote_amount', 'subtotal', 'integer');
select has_column('quote_amount', 'total');
select col_type_is('quote_amount', 'total', 'integer');
set client_min_messages to warning;
truncate quote_product_tax cascade;
truncate quote_product cascade;
truncate quote cascade;
truncate contact cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate payment_method cascade;
truncate company cascade;
reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id)
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (7, 1, 'Contact', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
;
insert into quote (quote_id, company_id, quote_number, quote_date, currency_code)
values ( 8, 1, 'I1', current_date, 'EUR')
, ( 9, 1, 'I2', current_date, 'EUR')
, (10, 1, 'I3', current_date, 'EUR')
, (11, 1, 'I4', current_date, 'EUR')
;
insert into quote_product (quote_product_id, quote_id, name, price, quantity, discount_rate)
values (12, 8, 'P', 100, 1, 0.0)
, (13, 8, 'P', 200, 2, 0.1)
, (14, 9, 'P', 222, 3, 0.0)
, (15, 9, 'P', 333, 4, 0.2)
, (16, 10, 'P', 444, 5, 0.0)
, (17, 10, 'P', 555, 6, 0.1)
, (18, 11, 'P', 777, 8, 0.0)
;
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
values (12, 2, -0.15)
, (12, 5, 0.21)
, (13, 3, 0.04)
, (14, 4, 0.10)
, (14, 5, 0.21)
, (14, 2, -0.07)
, (15, 4, 0.10)
, (16, 4, 0.10)
, (16, 5, 0.21)
, (17, 5, 0.21)
, (17, 3, 0.04)
;
select bag_eq(
$$ select quote_id, subtotal, total from quote_amount $$,
$$ values ( 8, 460, 480)
, ( 9, 1732, 1999)
, (10, 5217, 6654)
, (11, 6216, 6216)
$$,
'Should compute the amount for all taxes in the quoted products.'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,145 @@
-- Test quote_number_counter
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(28);
set search_path to numerus, auth, public;
select has_table('quote_number_counter');
select has_pk('quote_number_counter' );
select col_is_pk('quote_number_counter', array['company_id', 'year']);
select table_privs_are('quote_number_counter', 'guest', array []::text[]);
select table_privs_are('quote_number_counter', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE']);
select table_privs_are('quote_number_counter', 'admin', array ['SELECT', 'INSERT', 'UPDATE']);
select table_privs_are('quote_number_counter', 'authenticator', array []::text[]);
select has_column('quote_number_counter', 'company_id');
select col_is_fk('quote_number_counter', 'company_id');
select fk_ok('quote_number_counter', 'company_id', 'company', 'company_id');
select col_type_is('quote_number_counter', 'company_id', 'integer');
select col_not_null('quote_number_counter', 'company_id');
select col_hasnt_default('quote_number_counter', 'company_id');
select has_column('quote_number_counter', 'year');
select col_type_is('quote_number_counter', 'year', 'integer');
select col_not_null('quote_number_counter', 'year');
select col_hasnt_default('quote_number_counter', 'year');
select has_column('quote_number_counter', 'currval');
select col_type_is('quote_number_counter', 'currval', 'integer');
select col_not_null('quote_number_counter', 'currval');
select col_hasnt_default('quote_number_counter', 'currval');
set client_min_messages to warning;
truncate quote_number_counter cascade;
truncate company_user cascade;
truncate payment_method cascade;
truncate company cascade;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id)
values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 222)
, (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 444)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (444, 4, 'cash', 'cash')
, (222, 2, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into company_user (company_id, user_id)
values (2, 1)
, (4, 5)
;
insert into quote_number_counter (company_id, year, currval)
values (2, 2010, 6)
, (2, 2011, 8)
, (4, 2010, 8)
, (4, 2012, 10)
;
prepare counter_data as
select company_id, year, currval
from quote_number_counter
;
set role invoicer;
select is_empty('counter_data', 'Should show no data when cookie is not set yet');
reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog');
select bag_eq(
'counter_data',
$$ values (2, 2010, 6)
, (2, 2011, 8)
$$,
'Should only list quote numbers of the companies where demo@tandem.blog is user of'
);
reset role;
select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog');
select bag_eq(
'counter_data',
$$ values (4, 2010, 8)
, (4, 2012, 10)
$$,
'Should only list quote numbers of the companies where admin@tandem.blog is user of'
);
reset role;
select set_cookie('not-a-cookie');
select throws_ok(
'counter_data',
'42501', 'permission denied for table quote_number_counter',
'Should not allow select to guest users'
);
reset role;
select lives_ok( $$
insert into quote_number_counter (company_id, year, currval)
values (2, 2008, 0)
$$,
'Should allow starting a counter from zero'
);
select throws_ok( $$
insert into quote_number_counter (company_id, year, currval)
values (2, 2009, -1)
$$,
'23514', 'new row for relation "quote_number_counter" violates check constraint "counter_zero_or_positive"',
'Should not allow starting a counter from a negative value'
);
select throws_ok( $$
insert into quote_number_counter (company_id, year, currval)
values (2, -2008, 1)
$$,
'23514', 'new row for relation "quote_number_counter" violates check constraint "year_always_positive"',
'Should not allow counters for quotes issued before Jesus Christ was born'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,108 @@
-- Test quote_product_amount
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to numerus, auth, public;
select has_view('quote_product_amount');
select table_privs_are('quote_product_amount', 'guest', array[]::text[]);
select table_privs_are('quote_product_amount', 'invoicer', array['SELECT']);
select table_privs_are('quote_product_amount', 'admin', array['SELECT']);
select table_privs_are('quote_product_amount', 'authenticator', array[]::text[]);
select has_column('quote_product_amount', 'quote_product_id');
select col_type_is('quote_product_amount', 'quote_product_id', 'integer');
select has_column('quote_product_amount', 'subtotal');
select col_type_is('quote_product_amount', 'subtotal', 'integer');
select has_column('quote_product_amount', 'total');
select col_type_is('quote_product_amount', 'total', 'integer');
set client_min_messages to warning;
truncate quote_product_tax cascade;
truncate quote_product cascade;
truncate quote cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate payment_method cascade;
truncate company cascade;
reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id)
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
insert into quote (quote_id, company_id, quote_number, quote_date, currency_code)
values ( 8, 1, 'I1', current_date, 'EUR')
, ( 9, 1, 'I2', current_date, 'EUR')
, (10, 1, 'I3', current_date, 'EUR')
, (11, 1, 'I4', current_date, 'EUR')
;
insert into quote_product (quote_product_id, quote_id, name, price, quantity, discount_rate)
values (12, 8, 'P', 100, 1, 0.0)
, (13, 8, 'P', 200, 2, 0.1)
, (14, 9, 'P', 222, 3, 0.0)
, (15, 9, 'P', 333, 4, 0.2)
, (16, 10, 'P', 444, 5, 0.0)
, (17, 10, 'P', 555, 6, 0.1)
, (18, 11, 'P', 777, 8, 0.0)
;
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
values (12, 2, -0.15)
, (12, 5, 0.21)
, (13, 3, 0.04)
, (14, 4, 0.10)
, (14, 5, 0.21)
, (14, 2, -0.07)
, (15, 4, 0.10)
, (16, 4, 0.10)
, (16, 5, 0.21)
, (17, 5, 0.21)
, (17, 3, 0.04)
;
select bag_eq(
$$ select quote_product_id, subtotal, total from quote_product_amount $$,
$$ values (12, 100, 106)
, (13, 360, 374)
, (14, 666, 826)
, (15, 1066, 1173)
, (16, 2220, 2908)
, (17, 2997, 3746)
, (18, 6216, 6216)
$$,
'Should compute the subtotal and total for all products.'
);
select *
from finish();
rollback;

View File

@ -40,7 +40,7 @@ select col_hasnt_default('quote_product_tax', 'tax_rate');
set client_min_messages to warning;
truncate quote_product_tax cascade;
truncate quote_product cascade;
truncate invoice cascade;
truncate quote cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company_user cascade;

110
test/quote_tax_amount.sql Normal file
View File

@ -0,0 +1,110 @@
-- Test quote_tax_amount
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to numerus, auth, public;
select has_view('quote_tax_amount');
select table_privs_are('quote_tax_amount', 'guest', array[]::text[]);
select table_privs_are('quote_tax_amount', 'invoicer', array['SELECT']);
select table_privs_are('quote_tax_amount', 'admin', array['SELECT']);
select table_privs_are('quote_tax_amount', 'authenticator', array[]::text[]);
select has_column('quote_tax_amount', 'quote_id');
select col_type_is('quote_tax_amount', 'quote_id', 'integer');
select has_column('quote_tax_amount', 'tax_id');
select col_type_is('quote_tax_amount', 'tax_id', 'integer');
select has_column('quote_tax_amount', 'amount');
select col_type_is('quote_tax_amount', 'amount', 'integer');
set client_min_messages to warning;
truncate quote_product_tax cascade;
truncate quote_product cascade;
truncate quote cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate payment_method cascade;
truncate company cascade;
reset client_min_messages;
set constraints "company_default_payment_method_id_fkey" deferred;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id)
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
insert into quote (quote_id, company_id, quote_number, quote_date, currency_code)
values ( 8, 1, 'I1', current_date, 'EUR')
, ( 9, 1, 'I2', current_date, 'EUR')
, (10, 1, 'I3', current_date, 'EUR')
, (11, 1, 'I4', current_date, 'EUR')
;
insert into quote_product (quote_product_id, quote_id, name, price, quantity, discount_rate)
values (12, 8, 'P', 100, 1, 0.0)
, (13, 8, 'P', 200, 2, 0.1)
, (14, 9, 'P', 222, 3, 0.0)
, (15, 9, 'P', 333, 4, 0.2)
, (16, 10, 'P', 444, 5, 0.0)
, (17, 10, 'P', 555, 6, 0.1)
, (18, 11, 'P', 777, 8, 0.0)
;
insert into quote_product_tax (quote_product_id, tax_id, tax_rate)
values (12, 2, -0.15)
, (12, 5, 0.21)
, (13, 3, 0.04)
, (14, 4, 0.10)
, (14, 5, 0.21)
, (14, 2, -0.07)
, (15, 4, 0.10)
, (16, 4, 0.10)
, (16, 5, 0.21)
, (17, 5, 0.21)
, (17, 3, 0.04)
;
select bag_eq(
$$ select quote_id, tax_id, amount from quote_tax_amount $$,
$$ values ( 8, 2, -15)
, ( 8, 3, 14)
, ( 8, 5, 21)
, ( 9, 2, -47)
, ( 9, 4, 174)
, ( 9, 5, 140)
, (10, 3, 120)
, (10, 4, 222)
, (10, 5, 1095)
$$,
'Should compute the amount for all taxes in the quoted products.'
);
select *
from finish();
rollback;

7
verify/add_quote.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:add_quote on pg
begin;
select has_function_privilege('numerus.add_quote(integer, date, integer, text, text, integer, numerus.tag_name[], numerus.new_quote_product[])', 'execute');
rollback;

View File

@ -17,6 +17,7 @@ select company_id
, country_code
, currency_code
, invoice_number_format
, quote_number_format
, legal_disclaimer
, created_at
from numerus.company

View File

@ -0,0 +1,7 @@
-- Verify numerus:compute_new_quote_amount on pg
begin;
select has_function_privilege('numerus.compute_new_quote_amount(integer, numerus.new_quote_product[])', 'execute');
rollback;

7
verify/edit_quote.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:edit_quote on pg
begin;
select has_function_privilege('numerus.edit_quote(uuid, text, integer, text, text, integer, numerus.tag_name[], numerus.edited_quote_product[])', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:edited_quote_product on pg
begin;
select pg_catalog.has_type_privilege('numerus.edited_quote_product', 'usage');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:new_quote_amount on pg
begin;
select pg_catalog.has_type_privilege('numerus.new_invoice_amount', 'usage');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:new_quote_product on pg
begin;
select pg_catalog.has_type_privilege('numerus.new_quote_product', 'usage');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:next_quote_number on pg
begin;
select has_function_privilege('numerus.next_quote_number(integer, date)', 'execute');
rollback;

11
verify/quote_amount.sql Normal file
View File

@ -0,0 +1,11 @@
-- Verify numerus:quote_amount on pg
begin;
select quote_id
, subtotal
, total
from numerus.quote_amount
where false;
rollback;

View File

@ -0,0 +1,14 @@
-- Verify numerus:quote_number_counter on pg
begin;
select company_id
, year
, currval
from numerus.quote_number_counter
where false;
select 1 / count(*) from pg_class where oid = 'numerus.quote_number_counter'::regclass and relrowsecurity;
select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.quote_number_counter'::regclass;
rollback;

View File

@ -0,0 +1,11 @@
-- Verify numerus:quote_product_amount on pg
begin;
select quote_product_id
, subtotal
, total
from numerus.quote_product_amount
where false;
rollback;

View File

@ -0,0 +1,11 @@
-- Verify numerus:quote_tax_amount on pg
begin;
select quote_id
, tax_id
, amount
from numerus.quote_tax_amount
where false;
rollback;

View File

@ -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;

View File

@ -44,6 +44,7 @@
<nav aria-label="{{( pgettext "Main" "title" )}}" data-hx-target="main" data-hx-boost="true">
<ul>
<li><a href="{{ companyURI "/" }}">{{( pgettext "Dashboard" "nav" )}}</a></li>
<li><a href="{{ companyURI "/quotes" }}">{{( pgettext "Quotations" "nav" )}}</a></li>
<li><a href="{{ companyURI "/invoices" }}">{{( pgettext "Invoices" "nav" )}}</a></li>
<li><a href="{{ companyURI "/expenses" }}">{{( pgettext "Expenses" "nav" )}}</a></li>
<li><a href="{{ companyURI "/products" }}">{{( pgettext "Products" "nav" )}}</a></li>

View File

@ -165,6 +165,29 @@
</fieldset>
{{- end }}
{{ define "quote-product-form" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.quoteProductForm*/ -}}
<fieldset class="new-quote-product"
data-hx-select="unset"
data-hx-vals='{"index": {{ .Index }}}'
data-hx-include="[name='product.quantity.{{ .Index }}']"
>
<button type="submit" class="icon delete-product"
formnovalidate
name="action" value="remove-product.{{ .Index }}"
aria-label="{{( gettext "Delete product from quotation" )}}"
><i class="ri-delete-back-2-line"></i></button>
{{ template "hidden-field" .QuoteProductId }}
{{ template "hidden-field" .ProductId }}
{{ template "input-field" .Name }}
{{ template "input-field" .Price }}
{{ template "input-field" .Quantity }}
{{ template "input-field" .Discount }}
{{ template "input-field" .Description | addInputAttr `rows="1"`}}
{{ template "select-field" .Tax }}
</fieldset>
{{- end }}
{{ define "filters-toggle" -}}
<button id="filters-toggle" x-cloak x-data="{}"
@click="document.body.classList.toggle('filters-visible')"

View File

@ -0,0 +1,103 @@
{{ define "title" -}}
{{ printf ( pgettext "Edit Quotation “%s”" "title" ) .Number }}
{{- end }}
{{ define "breadcrumbs" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editQuotationPage*/ -}}
<nav data-hx-target="main" data-hx-boost="true">
<p>
<a href="{{ companyURI "/" }}">{{( pgettext "Home" "title" )}}</a> /
<a href="{{ companyURI "/quotes"}}">{{( pgettext "Quotations" "title" )}}</a> /
<a>{{ .Number }}</a>
</p>
</nav>
{{- end }}
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editQuotationPage*/ -}}
<section id="quote-dialog-content" data-hx-target="main">
<h2>{{ printf (pgettext "Edit Quotation “%s”" "title") .Number }}</h2>
<form method="POST" action="{{ companyURI "/quotes/" }}{{ .Slug }}/edit" data-hx-boost="true">
{{ csrfToken }}
{{ with .Form -}}
{{ if .RemovedProduct -}}
<div role="alert">
{{ with .RemovedProduct -}}
<p>{{printf (gettext "Product “%s” removed") .Name}}</p>
<button type="submit"
formnovalidate
name="action" value="restore-product"
>{{( pgettext "Undo" "action" )}}</button>
{{ 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 }}
</div>
{{- end }}
<div class="quote-data">
{{ 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 }}
</div>
{{- range $product := .Products }}
{{ template "quote-product-form" . }}
{{- end }}
{{- end }}
<table id="quote-summary">
<tbody>
<tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th>
<td class="numeric">{{ .Subtotal | formatPrice }}</td>
</tr>
{{- range $tax := .Taxes }}
<tr>
<th scope="row">{{ index . 0 }}</th>
<td class="numeric">{{ index . 1 | formatPrice }}</td>
</tr>
{{- end }}
<tr>
<th scope="row">{{(pgettext "Total" "title")}}</th>
<td class="numeric">{{ .Total | formatPrice }}</td>
</tr>
</tbody>
</table>
<fieldset class="button-bar">
<button formnovalidate
name="action" value="select-products"
data-hx-get="{{ companyURI "/quotes/product-form" }}"
data-hx-target="#quote-summary" data-hx-swap="beforebegin"
data-hx-select="unset"
data-hx-vals="js:{index: document.querySelectorAll('.new-quote-product').length}"
type="submit">{{( pgettext "Add products" "action" )}}</button>
<button formnovalidate
id="recompute-button"
name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="_method" value="PUT"
formaction="{{ companyURI "/quotes" }}/{{ .Slug }}"
type="submit">{{( pgettext "Save" "action" )}}</button>
</fieldset>
</form>
</section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }}

View File

@ -0,0 +1,142 @@
{{ define "title" -}}
{{( pgettext "Quotations" "title" )}}
{{- end }}
{{ define "breadcrumbs" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.QuotesIndexPage*/ -}}
<nav data-hx-target="main" data-hx-boost="true">
<p>
<a href="{{ companyURI "/" }}">{{( pgettext "Home" "title" )}}</a> /
<a>{{( pgettext "Quotations" "title" )}}</a>
</p>
<form id="batch-form" action="{{ companyURI "/quotes/batch" }}" method="post">
{{ csrfToken }}
<p>
{{ template "filters-toggle" }}
<button type="submit"
name="action" value="download"
>{{( pgettext "Download quotations" "action" )}}</button>
<a class="primary button"
href="{{ companyURI "/quotes/new" }}">{{( pgettext "New quotation" "action" )}}</a>
</p>
</form>
</nav>
{{- end }}
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.QuotesIndexPage*/ -}}
<form class="filters" method="GET" action="{{ companyURI "/quotes"}}"
data-hx-target="main" data-hx-boost="true" data-hx-trigger="change,search,submit"
aria-labelledby="filters-toggle"
>
{{ 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 }}
<noscript>
<button type="submit">{{( pgettext "Filter" "action" )}}</button>
</noscript>
</form>
<table id="quote-list">
<thead>
<tr>
<th>{{( pgettext "All" "quote" )}}</th>
<th>{{( pgettext "Date" "title" )}}</th>
<th>{{( pgettext "Quotation Num." "title" )}}</th>
<th>{{( pgettext "Customer" "title" )}}</th>
<th>{{( pgettext "Status" "title" )}}</th>
<th>{{( pgettext "Tags" "title" )}}</th>
<th>{{( pgettext "Amount" "title" )}}</th>
<th>{{( pgettext "Download" "title" )}}</th>
<th>{{( pgettext "Actions" "title" )}}</th>
</tr>
</thead>
<tbody>
{{ with .Quotes }}
{{- range $quote := . }}
<tr>
{{ $title := .Number | printf (pgettext "Select quotation %v" "action") }}
<td><input type="checkbox" form="batch-form"
name="quote" value="{{ .Slug }}"
aria-label="{{ $title }}"
title="{{ $title }}"/></td>
<td>{{ .Date|formatDate }}</td>
<td><a href="{{ companyURI "/quotes/"}}{{ .Slug }}" data-hx-target="main"
data-hx-boost="true">{{ .Number }}</a></td>
<td>{{ .CustomerName }}</td>
<td>
<details class="quote-status menu">
<summary class="quote-status-{{ .Status }}">{{ .StatusLabel }}</summary>
<form action="{{companyURI "/quotes/"}}{{ .Slug }}" method="POST" data-hx-boost="true">
{{ csrfToken }}
{{ putMethod }}
<input type="hidden" name="quick" value="status">
<ul role="menu">
{{- range $status, $name := $.QuoteStatuses }}
{{- if ne $status $quote.Status }}
<li role="presentation">
<button role="menuitem" type="submit"
name="quote_status" value="{{ $status }}"
class="quote-status-{{ $status }}"
>{{ $name }}</button>
</li>
{{- end }}
{{- end }}
</ul>
</form>
</details>
</td>
<td data-hx-get="{{companyURI "/quotes/"}}{{ .Slug }}/tags/edit"
data-hx-target="this"
data-hx-swap="outerHTML"
>
{{- range $index, $tag := .Tags }}
{{- if gt $index 0 }}, {{ end -}}
{{ . }}
{{- end }}
</td>
<td class="numeric">{{ .Total|formatPrice }}</td>
<td class="quote-download"><a href="{{ companyURI "/quotes/"}}{{ .Slug }}.pdf"
download="{{ .Number}}.pdf"
title="{{( pgettext "Download quotation" "action" )}}"
aria-label="{{( pgettext "Download quotation" "action" )}}"><i
class="ri-download-line"></i></a></td>
<td class="actions">
<details class="menu">
<summary><i class="ri-more-line"></i></summary>
<ul role="menu" class="action-menu">
<li role="presentation">
<a role="menuitem" href="{{ companyURI "/quotes"}}/{{ .Slug }}/edit"
data-hx-target="main" data-hx-boost="true"
>
<i class="ri-edit-line"></i>
{{( pgettext "Edit" "action" )}}
</a>
</li>
<li role="presentation">
<a role="menuitem" href="{{ companyURI "/quotes/new"}}?duplicate={{ .Slug }}"
data-hx-target="main" data-hx-boost="true"
>
<i class="ri-file-copy-line"></i>
{{( pgettext "Duplicate" "action" )}}
</a>
</li>
</ul>
</details>
</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="9">{{( gettext "No quotations added yet." )}}</td>
</tr>
{{ end }}
</tbody>
</table>
{{- end }}

View File

@ -0,0 +1,101 @@
{{ define "title" -}}
{{( pgettext "New Quotation" "title" )}}
{{- end }}
{{ define "breadcrumbs" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuotePage*/ -}}
<nav data-hx-target="main" data-hx-boost="true">
<p>
<a href="{{ companyURI "/" }}">{{( pgettext "Home" "title" )}}</a> /
<a href="{{ companyURI "/quotes"}}">{{( pgettext "Quotations" "title" )}}</a> /
<a>{{( pgettext "New Quotation" "title" )}}</a>
</p>
</nav>
{{- end }}
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuotePage*/ -}}
<section id="quote-dialog-content" data-hx-target="main">
<h2>{{(pgettext "New Quotation" "title")}}</h2>
<form method="POST" action="{{ companyURI "/quotes/new" }}" data-hx-boost="true">
{{ csrfToken }}
{{ with .Form -}}
{{ if .RemovedProduct -}}
<div role="alert">
{{ with .RemovedProduct -}}
<p>{{printf (gettext "Product “%s” removed") .Name}}</p>
<button type="submit"
formnovalidate
name="action" value="restore-product"
>{{( pgettext "Undo" "action" )}}</button>
{{ 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 }}
</div>
{{- end }}
<div class="quote-data">
{{ 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 }}
</div>
{{- range $product := .Products }}
{{ template "quote-product-form" . }}
{{- end }}
{{- end }}
<table id="quote-summary">
<tbody>
<tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th>
<td class="numeric">{{ .Subtotal | formatPrice }}</td>
</tr>
{{- range $tax := .Taxes }}
<tr>
<th scope="row">{{ index . 0 }}</th>
<td class="numeric">{{ index . 1 | formatPrice }}</td>
</tr>
{{- end }}
<tr>
<th scope="row">{{(pgettext "Total" "title")}}</th>
<td class="numeric">{{ .Total | formatPrice }}</td>
</tr>
</tbody>
</table>
<fieldset class="button-bar">
<button formnovalidate
name="action" value="select-products"
data-hx-get="{{ companyURI "/quotes/product-form" }}"
data-hx-target="#quote-summary" data-hx-swap="beforebegin"
data-hx-select="unset"
data-hx-vals="js:{index: document.querySelectorAll('.new-quote-product').length}"
type="submit">{{( pgettext "Add products" "action" )}}</button>
<button formnovalidate
id="recompute-button"
name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="action" value="add"
formaction="{{ companyURI "/quotes" }}"
type="submit">{{( pgettext "Save" "action" )}}</button>
</fieldset>
</form>
</section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }}

View File

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

View File

@ -0,0 +1,76 @@
{{ define "title" -}}
{{( pgettext "Add Products to Quotation" "title" )}}
{{- end }}
{{ define "breadcrumbs" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuoteProductsPage*/ -}}
<nav data-hx-target="main" data-hx-boost="true">
<p>
<a href="{{ companyURI "/" }}">{{( pgettext "Home" "title" )}}</a> /
<a href="{{ companyURI "/quotes"}}">{{( pgettext "Quotations" "title" )}}</a> /
{{ if eq .Form.Number "" }}
<a>{{( pgettext "New Quotation" "title" )}}</a>
{{ else }}
<a>{{ .Form.Number }}</a>
{{ end }}
</p>
</nav>
{{- end }}
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newQuotationProductsPage*/ -}}
<section id="quote-dialog-content" data-hx-target="this">
<h2>{{(pgettext "Add Products to Quotation" "title")}}</h2>
<form method="POST" action="{{ .Action }}" data-hx-boost="true" data-hx-select="#quote-dialog-content">
{{ 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 }}
<table>
<thead>
<tr>
<th>{{( pgettext "All" "product" )}}</th>
<th>{{( pgettext "Name" "title" )}}</th>
<th>{{( pgettext "Price" "title" )}}</th>
</tr>
</thead>
<tbody>
{{ with .Products }}
{{- range . }}
<tr>
<td><input type="checkbox" name="slug" id="product-{{ .Slug }}" value="{{.Slug}}"></td>
<td><label for="product-{{ .Slug }}">{{ .Name }}</label></td>
<td class="numeric">{{ .Price | formatPrice }}</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="4">{{( gettext "No products added yet." )}}</td>
</tr>
{{ end }}
</tbody>
</table>
<fieldset>
<button class="primary" type="submit"
name="action" value="add-products">{{( pgettext "Add products" "action" )}}</button>
</fieldset>
</form>
</section>
{{- end }}

View File

@ -0,0 +1,126 @@
{{ define "title" -}}
{{ .Number | printf ( pgettext "Quotation %s" "title" )}}
{{- end }}
{{ define "breadcrumbs" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.quote*/ -}}
<nav>
<p data-hx-target="main" data-hx-boost="true">
<a href="{{ companyURI "/" }}">{{( pgettext "Home" "title" )}}</a> /
<a href="{{ companyURI "/quotes"}}">{{( pgettext "Quotations" "title" )}}</a> /
<a>{{ .Number }}</a>
</p>
<p>
<a class="button primary"
data-hx-target="main" data-hx-boost="true"
href="{{ companyURI "/quotes/new"}}?duplicate={{ .Slug }}">{{( pgettext "Duplicate" "action" )}}</a>
<a class="button primary"
data-hx-target="main" data-hx-boost="true"
href="{{ companyURI "/quotes/"}}{{ .Slug }}/edit">{{( pgettext "Edit" "action" )}}</a>
<a class="primary button"
href="{{ companyURI "/quotes/" }}{{ .Slug }}.pdf"
download="{{ .Number}}.pdf">{{( pgettext "Download quotation" "action" )}}</a>
</p>
</nav>
{{- end }}
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.quote*/ -}}
<link rel="stylesheet" type="text/css" href="/static/invoice.css">
<article class="invoice">
<header>
<div>
<h1>{{ .Number | printf ( pgettext "Quotation %s" "title" )}}</h1>
<p class="date">{{( pgettext "Date" "title" )}} {{ .Date | formatDate }}</p>
</div>
<address class="quoter">
{{ .Quoter.Name }}<br>
{{ .Quoter.VATIN }}<br>
{{ .Quoter.Address }}<br>
{{ .Quoter.City }} ({{ .Quoter.PostalCode}}), {{ .Quoter.Province }}<br>
{{ .Quoter.Email }}<br>
{{ .Quoter.Phone }}<br>
</address>
<p class="legal">{{ .LegalDisclaimer }}</p>
</header>
<div>
<address class="quotee">
{{ .Quotee.Name }}<br>
{{ .Quotee.VATIN }}<br>
{{ .Quotee.Address }}<br>
{{ .Quotee.City }} ({{ .Quotee.PostalCode}}), {{ .Quotee.Province }}<br>
</address>
{{- $columns := 5 | add (len .TaxClasses) | add (boolToInt .HasDiscounts) -}}
<table>
<thead>
<tr>
<th>{{( pgettext "Concept" "title" )}}</th>
<th class="numeric">{{( pgettext "Price" "title" )}}</th>
{{ if .HasDiscounts -}}
<th class="numeric">{{( pgettext "Discount" "title" )}}</th>
{{ end -}}
<th class="numeric">{{( pgettext "Units" "title" )}}</th>
<th class="numeric">{{( pgettext "Subtotal" "title" )}}</th>
{{ range $class := .TaxClasses -}}
<th class="numeric">{{ . }}</th>
{{ end -}}
<th class="numeric">{{( pgettext "Total" "title" )}}</th>
</tr>
</thead>
{{ $lastIndex := len .Products | sub 1 }}
{{ range $index, $product := .Products -}}
<tbody>
{{- if .Description }}
<tr class="name">
<td colspan="{{ $columns }}">{{ .Name }}</td>
</tr>
{{ end -}}
<tr>
{{- if .Description }}
<td>{{ .Description }}</td>
{{- else }}
<td>{{ .Name }}</td>
{{- end -}}
<td class="numeric">{{ .Price | formatPrice }}</td>
{{ if $.HasDiscounts -}}
<td class="numeric">{{ $product.Discount | formatPercent }}</td>
{{ end -}}
<td class="numeric">{{ .Quantity }}</td>
<td class="numeric">{{ .Subtotal | formatPrice }}</td>
{{ range $class := $.TaxClasses -}}
<td class="numeric">{{ index $product.Taxes $class | formatPercent }}</td>
{{ end -}}
<td class="numeric">{{ .Total | formatPrice }}</td>
</tr>
{{ if (eq $index $lastIndex) }}
<tr class="tfoot separator">
<th scope="row" colspan="{{ $columns | sub 1 }}">{{( pgettext "Tax Base" "title" )}}</th>
<td class="numeric">{{ $.Subtotal | formatPrice }}</td>
</tr>
{{ range $tax := $.Taxes -}}
<tr class="tfoot">
<th scope="row" colspan="{{ $columns | sub 1 }}">{{ index . 0 }}</th>
<td class="numeric">{{ index . 1 | formatPrice }}</td>
</tr>
{{- end }}
<tr class="tfoot">
<th scope="row" colspan="{{ $columns | sub 1 }}">{{( pgettext "Total" "title" )}}</th>
<td class="numeric">{{ $.Total | formatPrice }}</td>
</tr>
{{ end }}
</tbody>
{{- end }}
</table>
{{ if .Notes -}}
<p class="notes">{{ .Notes }}</p>
{{- end }}
<p class="payment-instructions">{{ .PaymentInstructions }}</p>
</div>
</article>
{{- end}}