Add expenses statuses

We only want two statuses for expense: not yet paid (pending), and paid.
Thus, it is a bit different from quotes and invoices, because expenses
do not pass throw the “workflow” of created→sent→{pending,paid}. That’s
way in this case the status field is already in the new expense form,
instead of hidden, and by pending is not equivalent to created but
unpaid (i.e., the same status color).

With the new select field in the form, the file field no longer can
span two columns or it would be alone on the next row.

Closes #67.
This commit is contained in:
jordi fita mas 2023-07-11 15:33:26 +02:00
parent b7578a56df
commit b48a974086
35 changed files with 677 additions and 101 deletions

View File

@ -8,24 +8,27 @@
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
create or replace function add_expense(company integer, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, tags)
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, expense_status, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, status
, tags
from company
join currency using (currency_code)
@ -43,8 +46,10 @@ end;
$$
language plpgsql;
revoke execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to admin;
revoke execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to admin;
drop function if exists add_expense(integer, date, integer, text, text, integer[], tag_name[]);
commit;

50
deploy/add_expense@v1.sql Normal file
View File

@ -0,0 +1,50 @@
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -0,0 +1,22 @@
-- Deploy numerus:available_expense_status to pg
-- requires: schema_numerus
-- requires: expense_status
-- requires: expense_status_i18n
begin;
set search_path to numerus;
insert into expense_status (expense_status, name)
values ('pending', 'Pending')
, ('paid', 'Paid')
;
insert into expense_status_i18n (expense_status, lang_tag, name)
values ('pending', 'ca', 'Pendent')
, ('paid', 'ca', 'Pagada')
, ('pending', 'es', 'Pendiente')
, ('paid', 'es', 'Pagada')
;
commit;

View File

@ -5,12 +5,14 @@
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
create or replace function edit_expense(expense_slug uuid, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
@ -20,6 +22,7 @@ begin
, contact_id = edit_expense.contact_id
, invoice_number = edit_expense.invoice_number
, amount = parse_price(edit_expense.amount, decimal_digits)
, expense_status = status
, tags = edit_expense.tags
from currency
where slug = expense_slug
@ -43,8 +46,8 @@ end;
$$
language plpgsql;
revoke execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to admin;
revoke execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -0,0 +1,50 @@
-- Deploy numerus:edit_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
begin
update expense
set invoice_date = edit_expense.invoice_date
, contact_id = edit_expense.contact_id
, invoice_number = edit_expense.invoice_number
, amount = parse_price(edit_expense.amount, decimal_digits)
, tags = edit_expense.tags
from currency
where slug = expense_slug
and currency.currency_code = expense.currency_code
returning expense_id
into eid;
if eid is null then
return null;
end if;
delete from expense_tax where expense_id = eid;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return expense_slug;
end;
$$
language plpgsql;
revoke execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -0,0 +1,12 @@
-- Deploy numerus:expense_expense_status to pg
-- requires: expense
begin;
set search_path to numerus, public;
alter table expense
add column expense_status text not null default 'pending' references expense_status
;
commit;

17
deploy/expense_status.sql Normal file
View File

@ -0,0 +1,17 @@
-- Deploy numerus:expense_status to pg
-- requires: schema_numerus
-- requires: roles
begin;
set search_path to numerus, public;
create table expense_status (
expense_status text primary key,
name text not null
);
grant select on table expense_status to invoicer;
grant select on table expense_status to admin;
commit;

View File

@ -0,0 +1,21 @@
-- Deploy numerus:expense_status_i18n to pg
-- requires: schema_numerus
-- requires: roles
-- requires: expense_status
-- requires: language
begin;
set search_path to numerus, public;
create table expense_status_i18n (
expense_status text not null references expense_status,
lang_tag text not null references language,
name text not null,
primary key (expense_status, lang_tag)
);
grant select on table expense_status_i18n to invoicer;
grant select on table expense_status_i18n to admin;
commit;

View File

@ -20,12 +20,15 @@ type ExpenseEntry struct {
InvoicerName string
OriginalFileName string
Tags []string
Status string
StatusLabel string
}
type expensesIndexPage struct {
Expenses []*ExpenseEntry
TotalAmount string
Filters *expenseFilterForm
Expenses []*ExpenseEntry
TotalAmount string
Filters *expenseFilterForm
ExpenseStatuses map[string]string
}
func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
@ -38,15 +41,16 @@ func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
return
}
page := &expensesIndexPage{
Expenses: mustCollectExpenseEntries(r.Context(), conn, filters),
TotalAmount: mustComputeExpensesTotalAmount(r.Context(), conn, filters),
Filters: filters,
Expenses: mustCollectExpenseEntries(r.Context(), conn, locale, filters),
TotalAmount: mustComputeExpensesTotalAmount(r.Context(), conn, filters),
ExpenseStatuses: mustCollectExpenseStatuses(r.Context(), conn, locale),
Filters: filters,
}
mustRenderMainTemplate(w, r, "expenses/index.gohtml", page)
}
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expenseFilterForm) []*ExpenseEntry {
where, args := filters.BuildQuery(nil)
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, filters *expenseFilterForm) []*ExpenseEntry {
where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select expense.slug
, invoice_date
@ -55,9 +59,12 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expense
, contact.name
, coalesce(attachment.original_filename, '')
, expense.tags
, expense.expense_status
, esi18n.name
from expense
left join expense_attachment as attachment using (expense_id)
join contact using (contact_id)
join expense_status_i18n esi18n on expense.expense_status = esi18n.expense_status and esi18n.lang_tag = $1
join currency using (currency_code)
where (%s)
order by invoice_date
@ -67,7 +74,7 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expense
var entries []*ExpenseEntry
for rows.Next() {
entry := &ExpenseEntry{}
if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags); err != nil {
if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags, &entry.Status, &entry.StatusLabel); err != nil {
panic(err)
}
entries = append(entries, entry)
@ -79,6 +86,31 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expense
return entries
}
func mustCollectExpenseStatuses(ctx context.Context, conn *Conn, locale *Locale) map[string]string {
rows := conn.MustQuery(ctx, `
select expense_status.expense_status
, esi18n.name
from expense_status
join expense_status_i18n esi18n using(expense_status)
where esi18n.lang_tag = $1
order by expense_status`, locale.Language.String())
defer rows.Close()
statuses := map[string]string{}
for rows.Next() {
var key, name string
if err := rows.Scan(&key, &name); err != nil {
panic(err)
}
statuses[key] = name
}
if rows.Err() != nil {
panic(rows.Err())
}
return statuses
}
func mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) string {
where, args := filters.BuildQuery(nil)
return conn.MustGetText(ctx, "0", fmt.Sprintf(`
@ -138,6 +170,7 @@ type expenseForm struct {
Tax *SelectField
Amount *InputField
File *FileField
ExpenseStatus *SelectField
Tags *TagsField
}
@ -183,6 +216,13 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "File", locale),
MaxSize: 1 << 20,
},
ExpenseStatus: &SelectField{
Name: "expense_status",
Required: true,
Label: pgettext("input", "Expense Status", locale),
Selected: []string{"pending"},
Options: mustGetExpenseStatusOptions(ctx, conn, locale),
},
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
@ -190,6 +230,16 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
}
}
func mustGetExpenseStatusOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
return MustGetOptions(ctx, conn, `
select expense_status.expense_status
, esi18n.name
from expense_status
join expense_status_i18n esi18n using(expense_status)
where esi18n.lang_tag = $1
order by expense_status`, locale.Language.String())
}
func (form *expenseForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err
@ -202,6 +252,7 @@ func (form *expenseForm) Parse(r *http.Request) error {
if err := form.File.FillValue(r); err != nil {
return err
}
form.ExpenseStatus.FillValue(r)
form.Tags.FillValue(r)
return nil
}
@ -217,16 +268,20 @@ func (form *expenseForm) Validate() bool {
}
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
validator.CheckValidSelectOption(form.ExpenseStatus, gettext("Selected expense status is not valid.", form.locale))
return validator.AllOK()
}
func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
selectedExpenseStatus := form.ExpenseStatus.Selected
form.ExpenseStatus.Clear()
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select contact_id
, invoice_number
, invoice_date
, to_price(amount, decimal_digits)
, array_agg(tax_id)
, expense_status
, tags
from expense
left join expense_tax using (expense_id)
@ -237,6 +292,7 @@ func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
, invoice_date
, amount
, decimal_digits
, expense_status
, tags
`, slug).Scan(
form.Invoicer,
@ -244,7 +300,12 @@ func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.InvoiceDate,
form.Amount,
form.Tax,
form.Tags))
form.ExpenseStatus,
form.Tags)) {
form.ExpenseStatus.Selected = selectedExpenseStatus
return false
}
return true
}
func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
@ -267,7 +328,7 @@ func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return
}
taxes := mustSliceAtoi(form.Tax.Selected)
slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags)
slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7, $8)", company.Id, form.ExpenseStatus, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
@ -287,23 +348,31 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
http.Error(w, err.Error(), http.StatusForbidden)
return
}
slug := params[0].Value
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
if r.FormValue("quick") == "status" {
slug := conn.MustGetText(r.Context(), "", "update expense set expense_status = $1 where slug = $2 returning slug", form.ExpenseStatus, params[0].Value)
if slug == "" {
http.NotFound(w, r)
}
mustRenderEditExpenseForm(w, r, slug, form)
return
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/expenses"))
} else {
slug := params[0].Value
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderEditExpenseForm(w, r, slug, form)
return
}
taxes := mustSliceAtoi(form.Tax.Selected)
if found := conn.MustGetText(r.Context(), "", "select edit_expense($1, $2, $3, $4, $5, $6, $7, $8)", slug, form.ExpenseStatus, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags); found == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/expenses"))
}
taxes := mustSliceAtoi(form.Tax.Selected)
if found := conn.MustGetText(r.Context(), "", "select edit_expense($1, $2, $3, $4, $5, $6, $7)", slug, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags); found == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/expenses"))
}
type expenseFilterForm struct {
@ -313,6 +382,7 @@ type expenseFilterForm struct {
InvoiceNumber *InputField
FromDate *InputField
ToDate *InputField
ExpenseStatus *SelectField
Tags *TagsField
TagsCondition *ToggleField
}
@ -346,6 +416,12 @@ func newExpenseFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
ExpenseStatus: &SelectField{
Name: "expense_status",
Label: pgettext("input", "Expense Status", locale),
EmptyLabel: gettext("All status", locale),
Options: mustGetExpenseStatusOptions(ctx, conn, locale),
},
TagsCondition: &ToggleField{
Name: "tags_condition",
Label: pgettext("input", "Tags Condition", locale),
@ -372,6 +448,7 @@ func (form *expenseFilterForm) Parse(r *http.Request) error {
form.InvoiceNumber.FillValue(r)
form.FromDate.FillValue(r)
form.ToDate.FillValue(r)
form.ExpenseStatus.FillValue(r)
form.Tags.FillValue(r)
form.TagsCondition.FillValue(r)
return nil
@ -398,6 +475,7 @@ func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interfa
customerId, _ := strconv.Atoi(form.Contact.Selected[0])
return customerId
})
maybeAppendWhere("expense.expense_status = $%d", form.ExpenseStatus.String(), nil)
maybeAppendWhere("invoice_number = $%d", form.InvoiceNumber.String(), nil)
maybeAppendWhere("invoice_date >= $%d", form.FromDate.String(), nil)
maybeAppendWhere("invoice_date <= $%d", form.ToDate.String(), nil)

View File

@ -1,7 +1,52 @@
-- Revert numerus:add_expense from pg
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
drop function if exists numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[]);
set search_path to numerus, public;
drop function if exists add_expense(integer, text, date, integer, text, text, integer[], tag_name[]);
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

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

View File

@ -0,0 +1,10 @@
-- Revert numerus:available_expense_status from pg
begin;
set search_path to numerus;
delete from expense_status_i18n;
delete from expense_status;
commit;

View File

@ -1,7 +1,52 @@
-- Revert numerus:edit_expense from pg
-- Deploy numerus:edit_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
drop function if exists numerus.edit_expense(uuid, date, integer, text, text, integer[], numerus.tag_name[]);
set search_path to numerus, public;
drop function if exists edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]);
create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
begin
update expense
set invoice_date = edit_expense.invoice_date
, contact_id = edit_expense.contact_id
, invoice_number = edit_expense.invoice_number
, amount = parse_price(edit_expense.amount, decimal_digits)
, tags = edit_expense.tags
from currency
where slug = expense_slug
and currency.currency_code = expense.currency_code
returning expense_id
into eid;
if eid is null then
return null;
end if;
delete from expense_tax where expense_id = eid;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return expense_slug;
end;
$$
language plpgsql;
revoke execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

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

View File

@ -0,0 +1,9 @@
-- Revert numerus:expense_expense_status from pg
begin;
alter table numerus.expense
drop column if exists expense_status
;
commit;

View File

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

View File

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

View File

@ -115,3 +115,10 @@ input_is_valid [schema_public roles] 2023-07-03T08:42:46Z jordi fita mas <jordi@
input_is_valid_phone [schema_public roles extension_pg_libphonenumber] 2023-07-03T08:59:36Z jordi fita mas <jordi@tandem.blog> # add function to validate phone number inputs
import_contact [schema_numerus roles contact contact_web contact_phone contact_email contact_iban contact_swift contact_tax_details input_is_valid input_is_valid_phone] 2023-07-02T18:50:22Z jordi fita mas <jordi@tandem.blog> # Add functions to massively import customer data
@v1 2023-07-03T09:32:51Z jordi fita mas <jordi@tandem.blog> # Tag version 1
expense_status [schema_numerus roles] 2023-07-11T11:56:39Z jordi fita mas <jordi@tandem.blog> # Add the relation of expense status
expense_status_i18n [schema_numerus roles expense_status language] 2023-07-11T12:09:48Z jordi fita mas <jordi@tandem.blog> # Add relation for expense status translatable texts
available_expense_status [schema_numerus expense_status expense_status_i18n] 2023-07-11T12:13:45Z jordi fita mas <jordi@tandem.blog> # Add the list of available expense status
expense_expense_status [expense] 2023-07-11T12:28:58Z jordi fita mas <jordi@tandem.blog> # Add expense_status to expense relation
add_expense [add_expense@v1 expense_status expense_expense_status] 2023-07-11T13:16:16Z jordi fita mas <jordi@tandem.blog> # Add expense_status parameter to add_expense
edit_expense [edit_expense@v1 expense_status expense_expense_status] 2023-07-11T13:21:17Z jordi fita mas <jordi@tandem.blog> # Add expense_status parameter to edit_expense

View File

@ -9,15 +9,15 @@ select plan(14);
set search_path to auth, numerus, public;
select has_function('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select function_lang_is('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql');
select function_returns('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'uuid');
select isnt_definer('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select volatility_is('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'add_expense', array ['integer', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]);
select has_function('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select function_lang_is('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql');
select function_returns('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'uuid');
select isnt_definer('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select volatility_is('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'add_expense', array ['integer', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
@ -64,25 +64,25 @@ values (12, 1, 'Contact 2.1')
select lives_ok(
$$ select add_expense(1, '2023-05-02', 12, 'Invoice 1', '11.11', '{4}', '{tag1,tag2}') $$,
$$ select add_expense(1, 'pending', '2023-05-02', 12, 'Invoice 1', '11.11', '{4}', '{tag1,tag2}') $$,
'Should be able to insert an expense for the first company with a tax'
);
select lives_ok(
$$ select add_expense(1, '2023-05-03', 13, 'Invoice 2', '22.22', '{4,3}', '{}') $$,
$$ select add_expense(1, 'paid', '2023-05-03', 13, 'Invoice 2', '22.22', '{4,3}', '{}') $$,
'Should be able to insert a second expense for the first company with two taxes'
);
select lives_ok(
$$ select add_expense(2, '2023-05-04', 15, 'Invoice 3', '33.33', '{}', '{tag3}') $$,
$$ select add_expense(2, 'pending', '2023-05-04', 15, 'Invoice 3', '33.33', '{}', '{tag3}') $$,
'Should be able to insert an invoice for the second company with no tax'
);
select bag_eq(
$$ select company_id, invoice_number, invoice_date, contact_id, amount, currency_code, tags, created_at from expense $$,
$$ values (1, 'Invoice 1', '2023-05-02'::date, 12, 1111, 'EUR', '{tag1,tag2}'::tag_name[], current_timestamp)
, (1, 'Invoice 2', '2023-05-03'::date, 13, 2222, 'EUR', '{}'::tag_name[], current_timestamp)
, (2, 'Invoice 3', '2023-05-04'::date, 15, 3333, 'USD', '{tag3}'::tag_name[], current_timestamp)
$$ select company_id, invoice_number, invoice_date, contact_id, amount, currency_code, expense_status, tags, created_at from expense $$,
$$ values (1, 'Invoice 1', '2023-05-02'::date, 12, 1111, 'EUR', 'pending', '{tag1,tag2}'::tag_name[], current_timestamp)
, (1, 'Invoice 2', '2023-05-03'::date, 13, 2222, 'EUR', 'paid', '{}'::tag_name[], current_timestamp)
, (2, 'Invoice 3', '2023-05-04'::date, 15, 3333, 'USD', 'pending', '{tag3}'::tag_name[], current_timestamp)
$$,
'Should have created all expenses'
);

View File

@ -9,15 +9,15 @@ select plan(13);
set search_path to auth, numerus, public;
select has_function('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select function_lang_is('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql');
select function_returns('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'uuid');
select isnt_definer('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select volatility_is('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]);
select has_function('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select function_lang_is('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql');
select function_returns('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'uuid');
select isnt_definer('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]']);
select volatility_is('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_expense', array ['uuid', 'text', 'date', 'integer', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
@ -58,9 +58,9 @@ values (12, 1, 'Contact 2.1')
, (13, 1, 'Contact 2.2')
;
insert into expense (expense_id, company_id, slug, invoice_number, invoice_date, contact_id, amount, currency_code, tags)
values (15, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'INV1', '2023-05-04', 12, 111, 'EUR', '{tag1}')
, (16, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'INV2', '2023-05-05', 13, 222, 'EUR', '{tag2}')
insert into expense (expense_id, company_id, slug, invoice_number, invoice_date, contact_id, amount, currency_code, expense_status, tags)
values (15, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'INV1', '2023-05-04', 12, 111, 'EUR', 'pending', '{tag1}')
, (16, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'INV2', '2023-05-05', 13, 222, 'EUR', 'paid', '{tag2}')
;
insert into expense_tax (expense_id, tax_id, tax_rate)
@ -70,19 +70,19 @@ values (15, 4, 0.21)
;
select lives_ok(
$$ select edit_expense('7ac3ae0e-b0c1-4206-a19b-0be20835edd4', '2023-05-06', 13, 'INV11', '1.12', '{4}', array['tag1']) $$,
$$ select edit_expense('7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'paid', '2023-05-06', 13, 'INV11', '1.12', '{4}', array['tag1']) $$,
'Should be able to edit the first expense'
);
select lives_ok(
$$ select edit_expense('b57b980b-247b-4be4-a0b7-03a7819c53ae', '2023-05-07', 12, 'INV22', '3.33', '{4,3}', array['tag1', 'tag3']) $$,
$$ select edit_expense('b57b980b-247b-4be4-a0b7-03a7819c53ae', 'pending', '2023-05-07', 12, 'INV22', '3.33', '{4,3}', array['tag1', 'tag3']) $$,
'Should be able to edit the second expense'
);
select bag_eq(
$$ select invoice_number, invoice_date, contact_id, amount, tags from expense $$,
$$ values ('INV11', '2023-05-06'::date, 13, 112, '{tag1}'::tag_name[])
, ('INV22', '2023-05-07'::date, 12, 333, '{tag1,tag3}'::tag_name[])
$$ select invoice_number, invoice_date, contact_id, amount, expense_status, tags from expense $$,
$$ values ('INV11', '2023-05-06'::date, 13, 112, 'paid', '{tag1}'::tag_name[])
, ('INV22', '2023-05-07'::date, 12, 333, 'pending', '{tag1,tag3}'::tag_name[])
$$,
'Should have updated all expenses'
);

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin;
select plan(67);
select plan(74);
set search_path to numerus, auth, public;
@ -65,6 +65,14 @@ select col_type_is('expense', 'amount', 'integer');
select col_not_null('expense', 'amount');
select col_hasnt_default('expense', 'amount');
select has_column('expense', 'expense_status');
select col_is_fk('expense', 'expense_status');
select fk_ok('expense', 'expense_status', 'expense_status', 'expense_status');
select col_type_is('expense', 'expense_status', 'text');
select col_not_null('expense', 'expense_status');
select col_has_default('expense', 'expense_status');
select col_default_is('expense', 'expense_status', 'pending');
select has_column('expense', 'currency_code');
select col_is_fk('expense', 'currency_code');
select fk_ok('expense', 'currency_code', 'currency', 'currency_code');

33
test/expense_status.sql Normal file
View File

@ -0,0 +1,33 @@
-- Test expense_status
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(15);
set search_path to numerus, public;
select has_table('expense_status');
select has_pk('expense_status' );
select table_privs_are('expense_status', 'guest', array []::text[]);
select table_privs_are('expense_status', 'invoicer', array ['SELECT']);
select table_privs_are('expense_status', 'admin', array ['SELECT']);
select table_privs_are('expense_status', 'authenticator', array []::text[]);
select has_column('expense_status', 'expense_status');
select col_is_pk('expense_status', 'expense_status');
select col_type_is('expense_status', 'expense_status', 'text');
select col_not_null('expense_status', 'expense_status');
select col_hasnt_default('expense_status', 'expense_status');
select has_column('expense_status', 'name');
select col_type_is('expense_status', 'name', 'text');
select col_not_null('expense_status', 'name');
select col_hasnt_default('expense_status', 'name');
select *
from finish();
rollback;

View File

@ -0,0 +1,44 @@
-- Test expense_status_i18n
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(23);
set search_path to numerus, public;
select has_table('expense_status_i18n');
select has_pk('expense_status_i18n' );
select col_is_pk('expense_status_i18n', array['expense_status', 'lang_tag']);
select table_privs_are('expense_status_i18n', 'guest', array []::text[]);
select table_privs_are('expense_status_i18n', 'invoicer', array ['SELECT']);
select table_privs_are('expense_status_i18n', 'admin', array ['SELECT']);
select table_privs_are('expense_status_i18n', 'authenticator', array []::text[]);
select has_column('expense_status_i18n', 'expense_status');
select col_is_fk('expense_status_i18n', 'expense_status');
select fk_ok('expense_status_i18n', 'expense_status', 'expense_status', 'expense_status');
select col_type_is('expense_status_i18n', 'expense_status', 'text');
select col_not_null('expense_status_i18n', 'expense_status');
select col_hasnt_default('expense_status_i18n', 'expense_status');
select has_column('expense_status_i18n', 'lang_tag');
select col_is_fk('expense_status_i18n', 'lang_tag');
select fk_ok('expense_status_i18n', 'lang_tag', 'language', 'lang_tag');
select col_type_is('expense_status_i18n', 'lang_tag', 'text');
select col_not_null('expense_status_i18n', 'lang_tag');
select col_hasnt_default('expense_status_i18n', 'lang_tag');
select has_column('expense_status_i18n', 'name');
select col_type_is('expense_status_i18n', 'name', 'text');
select col_not_null('expense_status_i18n', 'name');
select col_hasnt_default('expense_status_i18n', 'name');
select *
from finish();
rollback;

View File

@ -2,6 +2,6 @@
begin;
select has_function_privilege('numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[])', 'execute');
select has_function_privilege('numerus.add_expense(integer, text, date, integer, text, text, integer[], numerus.tag_name[])', 'execute');
rollback;

View File

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

View File

@ -0,0 +1,15 @@
-- Verify numerus:available_expense_status on pg
begin;
set search_path to numerus;
select 1 / count(*) from expense_status where expense_status = 'pending' and name ='Pending';
select 1 / count(*) from expense_status where expense_status = 'paid' and name ='Paid';
select 1 / count(*) from expense_status_i18n where expense_status = 'pending' and name ='Pendent' and lang_tag = 'ca';
select 1 / count(*) from expense_status_i18n where expense_status = 'pending' and name ='Pendiente' and lang_tag = 'es';
select 1 / count(*) from expense_status_i18n where expense_status = 'paid' and name ='Pagada' and lang_tag= 'ca';
select 1 / count(*) from expense_status_i18n where expense_status = 'paid' and name ='Pagada' and lang_tag= 'es';
rollback;

View File

@ -2,6 +2,6 @@
begin;
select has_function_privilege('numerus.edit_expense(uuid, date, integer, text, text, integer[], numerus.tag_name[])', 'execute');
select has_function_privilege('numerus.edit_expense(uuid, text, date, integer, text, text, integer[], numerus.tag_name[])', 'execute');
rollback;

View File

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

View File

@ -0,0 +1,10 @@
-- Verify numerus:expense_expense_status on pg
begin;
select expense_status
from numerus.expense
where false
;
rollback;

10
verify/expense_status.sql Normal file
View File

@ -0,0 +1,10 @@
-- Verify numerus:expense_status on pg
begin;
select expense_status
, name
from numerus.expense_status
where false;
rollback;

View File

@ -0,0 +1,11 @@
-- Verify numerus:expense_status_i18n on pg
begin;
select expense_status
, lang_tag
, name
from numerus.expense_status_i18n
where false;
rollback;

View File

@ -614,16 +614,19 @@ main > nav {
}
.quote-status,
.expense-status,
.invoice-status {
position: relative;
}
.quote-status summary,
.expense-status summary,
.invoice-status summary {
height: 3rem;
}
.quote-status ul,
.expense-status ul,
.invoice-status ul {
position: absolute;
top: 0;
@ -635,12 +638,14 @@ main > nav {
}
.quote-status button,
.expense-status button,
.invoice-status button {
border: 0;
min-width: 15rem;
}
[class^='quote-status-'],
[class^='expense-status-'],
[class^='invoice-status-'] {
text-align: center;
text-transform: uppercase;
@ -658,11 +663,13 @@ main > nav {
}
.quote-status-accepted,
.expense-status-paid,
.invoice-status-paid {
background-color: var(--numerus--color--light-green);
}
.quote-status-rejected,
.expense-status-pending,
.invoice-status-unpaid {
background-color: var(--numerus--color--rosy);
}
@ -703,11 +710,6 @@ main > nav {
/* expenses */
.expenses-data div:last-child {
grid-column-start: 3;
grid-column-end: 5;
}
/* product */
.product-data div:last-child {
grid-column-start: 1;

View File

@ -18,20 +18,22 @@
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editExpensePage*/ -}}
<section id="new-expense-dialog-content" data-hx-target="main">
<h2>{{ printf (pgettext "Edit Expense “%s”" "title") .Slug }}</h2>
<form enctype="multipart/form-data" method="POST" action="{{ companyURI "/expenses/" }}{{ .Slug }}" data-hx-boost="true">
<form enctype="multipart/form-data" method="POST" action="{{ companyURI "/expenses/" }}{{ .Slug }}"
data-hx-boost="true">
{{ csrfToken }}
{{ putMethod }}
{{ with .Form -}}
<div class="expenses-data">
{{ template "select-field" .Invoicer }}
{{ template "input-field" .InvoiceNumber }}
{{ template "input-field" .InvoiceDate }}
{{ template "input-field" .Amount }}
{{ template "select-field" .Tax }}
{{ template "tags-field" .Tags }}
{{ template "file-field" .File }}
</div>
<div class="expenses-data">
{{ template "select-field" .Invoicer }}
{{ template "input-field" .InvoiceNumber }}
{{ template "input-field" .InvoiceDate }}
{{ template "input-field" .Amount }}
{{ template "select-field" .Tax }}
{{ template "tags-field" .Tags }}
{{ template "select-field" .ExpenseStatus }}
{{ template "file-field" .File }}
</div>
{{- end }}
<fieldset>

View File

@ -26,6 +26,7 @@
>
{{ with .Filters }}
{{ template "select-field" .Contact }}
{{ template "select-field" .ExpenseStatus }}
{{ template "input-field" .FromDate }}
{{ template "input-field" .ToDate }}
{{ template "input-field" .InvoiceNumber }}
@ -42,6 +43,7 @@
<th>{{( pgettext "Contact" "title" )}}</th>
<th>{{( pgettext "Invoice Date" "title" )}}</th>
<th>{{( pgettext "Invoice Number" "title" )}}</th>
<th>{{( pgettext "Status" "title" )}}</th>
<th>{{( pgettext "Tags" "title" )}}</th>
<th>{{( pgettext "Amount" "title" )}}</th>
<th>{{( pgettext "Download" "title" )}}</th>
@ -50,11 +52,33 @@
</thead>
<tbody>
{{ with .Expenses }}
{{- range . }}
{{- range $expense := . }}
<tr>
<td>{{ .InvoicerName }}</td>
<td>{{ .InvoiceDate|formatDate }}</td>
<td>{{ .InvoiceNumber }}</td>
<td>
<details class="expense-status menu">
<summary class="expense-status-{{ .Status }}">{{ .StatusLabel }}</summary>
<form action="{{companyURI "/expenses/"}}{{ .Slug }}" enctype="multipart/form-data" method="POST" data-hx-boost="true">
{{ csrfToken }}
{{ putMethod }}
<input type="hidden" name="quick" value="status">
<ul role="menu">
{{- range $status, $name := $.ExpenseStatuses }}
{{- if ne $status $expense.Status }}
<li role="presentation">
<button role="menuitem" type="submit"
name="expense_status" value="{{ $status }}"
class="expense-status-{{ $status }}"
>{{ $name }}</button>
</li>
{{- end }}
{{- end }}
</ul>
</form>
</details>
</td>
<td
data-hx-get="{{companyURI "/expenses/"}}{{ .Slug }}/tags/edit"
data-hx-target="this"

View File

@ -20,15 +20,16 @@
<h2>{{(pgettext "New Expense" "title")}}</h2>
<form enctype="multipart/form-data" method="POST" action="{{ companyURI "/expenses" }}" data-hx-boost="true">
{{ csrfToken }}
<div class="expenses-data">
{{ template "select-field" .Invoicer }}
{{ template "input-field" .InvoiceNumber }}
{{ template "input-field" .InvoiceDate }}
{{ template "input-field" .Amount }}
{{ template "select-field" .Tax }}
{{ template "tags-field" .Tags }}
{{ template "file-field" .File }}
</div>
<div class="expenses-data">
{{ template "select-field" .Invoicer }}
{{ template "input-field" .InvoiceNumber }}
{{ template "input-field" .InvoiceDate }}
{{ template "input-field" .Amount }}
{{ template "select-field" .Tax }}
{{ template "tags-field" .Tags }}
{{ template "select-field" .ExpenseStatus }}
{{ template "file-field" .File }}
</div>
<fieldset>
<button class="primary" type="submit">{{( pgettext "Save" "action" )}}</button>
</fieldset>