Compare commits
No commits in common. "ee2ed598a30304590966f9d831c73c4f790a1271" and "5d46bbb95b50835fdc65872ce42605a1bdba58fa" have entirely different histories.
ee2ed598a3
...
5d46bbb95b
|
@ -18,7 +18,6 @@ type ExpenseEntry struct {
|
||||||
InvoiceNumber string
|
InvoiceNumber string
|
||||||
Amount string
|
Amount string
|
||||||
InvoicerName string
|
InvoicerName string
|
||||||
OriginalFileName string
|
|
||||||
Tags []string
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,10 +78,8 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company
|
||||||
, invoice_number
|
, invoice_number
|
||||||
, to_price(amount, decimal_digits)
|
, to_price(amount, decimal_digits)
|
||||||
, contact.business_name
|
, contact.business_name
|
||||||
, coalesce(attachment.original_filename, '')
|
|
||||||
, expense.tags
|
, expense.tags
|
||||||
from expense
|
from expense
|
||||||
left join expense_attachment as attachment using (expense_id)
|
|
||||||
join contact using (contact_id)
|
join contact using (contact_id)
|
||||||
join currency using (currency_code)
|
join currency using (currency_code)
|
||||||
where (%s)
|
where (%s)
|
||||||
|
@ -93,7 +90,7 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company
|
||||||
var entries []*ExpenseEntry
|
var entries []*ExpenseEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
entry := &ExpenseEntry{}
|
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.Tags); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
|
@ -151,7 +148,6 @@ type expenseForm struct {
|
||||||
InvoiceDate *InputField
|
InvoiceDate *InputField
|
||||||
Tax *SelectField
|
Tax *SelectField
|
||||||
Amount *InputField
|
Amount *InputField
|
||||||
File *FileField
|
|
||||||
Tags *TagsField
|
Tags *TagsField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,11 +188,6 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
|
||||||
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
File: &FileField{
|
|
||||||
Name: "file",
|
|
||||||
Label: pgettext("input", "File", locale),
|
|
||||||
MaxSize: 1 << 20,
|
|
||||||
},
|
|
||||||
Tags: &TagsField{
|
Tags: &TagsField{
|
||||||
Name: "tags",
|
Name: "tags",
|
||||||
Label: pgettext("input", "Tags", locale),
|
Label: pgettext("input", "Tags", locale),
|
||||||
|
@ -205,7 +196,7 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
|
||||||
}
|
}
|
||||||
|
|
||||||
func (form *expenseForm) Parse(r *http.Request) error {
|
func (form *expenseForm) Parse(r *http.Request) error {
|
||||||
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
form.Invoicer.FillValue(r)
|
form.Invoicer.FillValue(r)
|
||||||
|
@ -213,9 +204,6 @@ func (form *expenseForm) Parse(r *http.Request) error {
|
||||||
form.InvoiceDate.FillValue(r)
|
form.InvoiceDate.FillValue(r)
|
||||||
form.Tax.FillValue(r)
|
form.Tax.FillValue(r)
|
||||||
form.Amount.FillValue(r)
|
form.Amount.FillValue(r)
|
||||||
if err := form.File.FillValue(r); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
form.Tags.FillValue(r)
|
form.Tags.FillValue(r)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -281,10 +269,7 @@ func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Param
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
taxes := mustSliceAtoi(form.Tax.Selected)
|
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)
|
conn.MustExec(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)
|
||||||
if len(form.File.Content) > 0 {
|
|
||||||
conn.MustQuery(r.Context(), "insert into expense_attachment(expense_id, original_filename, mime_type, content) select expense_id, $1, $2, $3 from expense where slug = $4", form.File.OriginalFileName, form.File.ContentType, form.File.Content, slug)
|
|
||||||
}
|
|
||||||
htmxRedirect(w, r, companyURI(company, "/expenses"))
|
htmxRedirect(w, r, companyURI(company, "/expenses"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -314,9 +299,6 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(form.File.Content) > 0 {
|
|
||||||
conn.MustQuery(r.Context(), "insert into expense_attachment(expense_id, original_filename, mime_type, content) select expense_id, $1, $2, $3 from expense where slug = $4 on conflict (expense_id) do update set original_filename = excluded.original_filename, mime_type = excluded.mime_type, content = excluded.content", form.File.OriginalFileName, form.File.ContentType, form.File.Content, slug)
|
|
||||||
}
|
|
||||||
htmxRedirect(w, r, companyURI(company, "/expenses"))
|
htmxRedirect(w, r, companyURI(company, "/expenses"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -423,24 +405,3 @@ func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params http
|
||||||
}
|
}
|
||||||
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
|
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
||||||
slug := params[0].Value
|
|
||||||
conn := getConn(r)
|
|
||||||
var contentType string
|
|
||||||
var content []byte
|
|
||||||
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `
|
|
||||||
select mime_type
|
|
||||||
, content
|
|
||||||
from expense
|
|
||||||
join expense_attachment using (expense_id)
|
|
||||||
where slug = $1
|
|
||||||
`, slug).Scan(&contentType, &content)) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(content)
|
|
||||||
}
|
|
||||||
|
|
32
pkg/form.go
32
pkg/form.go
|
@ -7,7 +7,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/jackc/pgtype"
|
"github.com/jackc/pgtype"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -217,37 +216,6 @@ func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*S
|
||||||
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
|
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileField struct {
|
|
||||||
Name string
|
|
||||||
Label string
|
|
||||||
MaxSize int64
|
|
||||||
OriginalFileName string
|
|
||||||
ContentType string
|
|
||||||
Content []byte
|
|
||||||
Errors []error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (field *FileField) FillValue(r *http.Request) error {
|
|
||||||
file, header, err := r.FormFile(field.Name)
|
|
||||||
if err != nil {
|
|
||||||
if err == http.ErrMissingFile {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
field.Content, err = ioutil.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
field.OriginalFileName = header.Filename
|
|
||||||
field.ContentType = header.Header.Get("Content-Type")
|
|
||||||
if len(field.Content) == 0 {
|
|
||||||
field.ContentType = http.DetectContentType(field.Content)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type TagsField struct {
|
type TagsField struct {
|
||||||
Name string
|
Name string
|
||||||
Label string
|
Label string
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
package pkg
|
package pkg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(db *Db) http.Handler {
|
func NewRouter(db *Db) http.Handler {
|
||||||
|
@ -44,7 +44,6 @@ func NewRouter(db *Db) http.Handler {
|
||||||
companyRouter.PUT("/expenses/:slug", HandleUpdateExpense)
|
companyRouter.PUT("/expenses/:slug", HandleUpdateExpense)
|
||||||
companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags)
|
companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags)
|
||||||
companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags)
|
companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags)
|
||||||
companyRouter.GET("/expenses/:slug/download/:filename", ServeExpenseAttachment)
|
|
||||||
companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
mustRenderMainTemplate(w, r, "dashboard.gohtml", nil)
|
mustRenderMainTemplate(w, r, "dashboard.gohtml", nil)
|
||||||
})
|
})
|
||||||
|
@ -86,23 +85,10 @@ func NewRouter(db *Db) http.Handler {
|
||||||
func MethodOverrider(next http.Handler) http.Handler {
|
func MethodOverrider(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
contentType := r.Header.Get("Content-Type")
|
|
||||||
contentType, _, err := mime.ParseMediaType(contentType)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if contentType == "multipart/form-data" {
|
|
||||||
if err := r.ParseMultipartForm(20 << 20); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
override := r.FormValue(overrideMethodName)
|
override := r.FormValue(overrideMethodName)
|
||||||
if override == http.MethodDelete || override == http.MethodPut {
|
if override == http.MethodDelete || override == http.MethodPut {
|
||||||
r2 := new(http.Request)
|
r2 := new(http.Request)
|
||||||
|
|
|
@ -80,10 +80,6 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
||||||
"putMethod": func() template.HTML {
|
"putMethod": func() template.HTML {
|
||||||
return overrideMethodField(http.MethodPut)
|
return overrideMethodField(http.MethodPut)
|
||||||
},
|
},
|
||||||
"humanizeBytes": func(bytes int64) string {
|
|
||||||
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
|
|
||||||
return humanizeBytes(bytes, 1024, sizes)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil {
|
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
@ -133,22 +129,3 @@ func mustRenderStandaloneTemplate(w io.Writer, r *http.Request, filename string,
|
||||||
func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
|
func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
|
||||||
mustRenderTemplate(w, r, "web.gohtml", filename, data)
|
mustRenderTemplate(w, r, "web.gohtml", filename, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanizeBytes(s int64, base float64, sizes []string) string {
|
|
||||||
if s < 10 {
|
|
||||||
return fmt.Sprintf("%d B", s)
|
|
||||||
}
|
|
||||||
e := math.Floor(logn(float64(s), base))
|
|
||||||
suffix := sizes[int(e)]
|
|
||||||
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
|
|
||||||
f := "%.0f %s"
|
|
||||||
if val < 10 {
|
|
||||||
f = "%.1f %s"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(f, val, suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logn(n, b float64) float64 {
|
|
||||||
return math.Log(n) / math.Log(b)
|
|
||||||
}
|
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editExpensePage*/ -}}
|
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editExpensePage*/ -}}
|
||||||
<section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main">
|
<section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main">
|
||||||
<h2>{{ printf (pgettext "Edit Expense “%s”" "title") .Slug }}</h2>
|
<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 method="POST" action="{{ companyURI "/expenses/" }}{{ .Slug }}" data-hx-boost="true">
|
||||||
{{ csrfToken }}
|
{{ csrfToken }}
|
||||||
{{ putMethod }}
|
{{ putMethod }}
|
||||||
|
|
||||||
|
@ -28,7 +28,6 @@
|
||||||
{{ template "input-field" .InvoiceDate }}
|
{{ template "input-field" .InvoiceDate }}
|
||||||
{{ template "input-field" .Amount }}
|
{{ template "input-field" .Amount }}
|
||||||
{{ template "select-field" .Tax }}
|
{{ template "select-field" .Tax }}
|
||||||
{{ template "file-field" .File }}
|
|
||||||
{{ template "tags-field" .Tags }}
|
{{ template "tags-field" .Tags }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,6 @@
|
||||||
<th>{{( pgettext "Invoice Number" "title" )}}</th>
|
<th>{{( pgettext "Invoice Number" "title" )}}</th>
|
||||||
<th>{{( pgettext "Tags" "title" )}}</th>
|
<th>{{( pgettext "Tags" "title" )}}</th>
|
||||||
<th>{{( pgettext "Amount" "title" )}}</th>
|
<th>{{( pgettext "Amount" "title" )}}</th>
|
||||||
<th>{{( pgettext "Download" "title" )}}</th>
|
|
||||||
<th>{{( pgettext "Actions" "title" )}}</th>
|
<th>{{( pgettext "Actions" "title" )}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -66,14 +65,6 @@
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric">{{ .Amount | formatPrice }}</td>
|
<td class="numeric">{{ .Amount | formatPrice }}</td>
|
||||||
<td class="invoice-download">
|
|
||||||
{{ if .OriginalFileName }}
|
|
||||||
<a href="{{ companyURI "/expenses/"}}{{ .Slug }}/download/{{.OriginalFileName}}"
|
|
||||||
title="{{( pgettext "Download expense attachment" "action" )}}"
|
|
||||||
aria-label="{{( pgettext "Download expense attachment" "action" )}}"><i
|
|
||||||
class="ri-download-line"></i></a>
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<details class="menu">
|
<details class="menu">
|
||||||
<summary><i class="ri-more-line"></i></summary>
|
<summary><i class="ri-more-line"></i></summary>
|
||||||
|
@ -93,7 +84,7 @@
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8">{{( gettext "No expenses added yet." )}}</td>
|
<td colspan="7">{{( gettext "No expenses added yet." )}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.expenseForm*/ -}}
|
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.expenseForm*/ -}}
|
||||||
<section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main">
|
<section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main">
|
||||||
<h2>{{(pgettext "New Expense" "title")}}</h2>
|
<h2>{{(pgettext "New Expense" "title")}}</h2>
|
||||||
<form enctype="multipart/form-data" method="POST" action="{{ companyURI "/expenses" }}" data-hx-boost="true">
|
<form method="POST" action="{{ companyURI "/expenses" }}" data-hx-boost="true">
|
||||||
{{ csrfToken }}
|
{{ csrfToken }}
|
||||||
|
|
||||||
{{ template "select-field" .Invoicer }}
|
{{ template "select-field" .Invoicer }}
|
||||||
|
@ -26,7 +26,6 @@
|
||||||
{{ template "input-field" .InvoiceDate }}
|
{{ template "input-field" .InvoiceDate }}
|
||||||
{{ template "input-field" .Amount }}
|
{{ template "input-field" .Amount }}
|
||||||
{{ template "select-field" .Tax }}
|
{{ template "select-field" .Tax }}
|
||||||
{{ template "file-field" .File }}
|
|
||||||
{{ template "tags-field" .Tags }}
|
{{ template "tags-field" .Tags }}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|
|
@ -29,21 +29,6 @@
|
||||||
</div>
|
</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{ define "file-field" -}}
|
|
||||||
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.FileField*/ -}}
|
|
||||||
<div class="input{{ if .Errors }} has-errors{{ end }}">
|
|
||||||
<input type="file" name="{{ .Name }}" id="{{ .Name }}-field" placeholder="{{ .Label }}">
|
|
||||||
<label for="{{ .Name }}-field">{{ .Label }}{{ if gt .MaxSize 0 }} {{printf (pgettext "(Max. %s)" "label") (.MaxSize|humanizeBytes) }}{{ end }}</label>
|
|
||||||
{{- if .Errors }}
|
|
||||||
<ul>
|
|
||||||
{{- range $error := .Errors }}
|
|
||||||
<li>{{ . }}</li>
|
|
||||||
{{- end }}
|
|
||||||
</ul>
|
|
||||||
{{- end }}
|
|
||||||
</div>
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{ define "tags-field" -}}
|
{{ define "tags-field" -}}
|
||||||
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}}
|
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}}
|
||||||
<div class="input {{ if .Errors }}has-errors{{ end }}" is="numerus-tags">
|
<div class="input {{ if .Errors }}has-errors{{ end }}" is="numerus-tags">
|
||||||
|
|
|
@ -131,7 +131,7 @@
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9">{{( gettext "No invoices added yet." )}}</td>
|
<td colspan="7">{{( gettext "No invoices added yet." )}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
Loading…
Reference in New Issue