Add filtering and pagination for bookings
This commit is contained in:
parent
b2ee4dfea3
commit
5d4fe15e88
|
@ -9,6 +9,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"dev.tandem.ws/tandem/camper/pkg/ods"
|
"dev.tandem.ws/tandem/camper/pkg/ods"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -137,17 +138,27 @@ func (h *AdminHandler) bookingHandler(user *auth.User, company *auth.Company, co
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveBookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
|
func serveBookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
|
||||||
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language)
|
filters := newFilterForm(r.Context(), conn, company, user.Locale)
|
||||||
|
if err := filters.Parse(r); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
page := bookingIndex(bookings)
|
page := &bookingIndex{
|
||||||
|
Bookings: filters.buildCursor(bookings),
|
||||||
|
Filters: filters,
|
||||||
|
}
|
||||||
page.MustRender(w, r, user, company)
|
page.MustRender(w, r, user, company)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag) ([]*bookingEntry, error) {
|
func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag, filters *filterForm) ([]*bookingEntry, error) {
|
||||||
rows, err := conn.Query(ctx, `
|
where, args := filters.BuildQuery([]interface{}{lang.String()})
|
||||||
select left(slug::text, 10)
|
rows, err := conn.Query(ctx, fmt.Sprintf(`
|
||||||
|
select booking_id
|
||||||
|
, left(slug::text, 10)
|
||||||
, '/admin/bookings/' || slug
|
, '/admin/bookings/' || slug
|
||||||
, lower(stay)
|
, lower(stay)
|
||||||
, upper(stay)
|
, upper(stay)
|
||||||
|
@ -157,8 +168,11 @@ func collectBookingEntries(ctx context.Context, conn *database.Conn, lang langua
|
||||||
from booking
|
from booking
|
||||||
join booking_status as status using (booking_status)
|
join booking_status as status using (booking_status)
|
||||||
left join booking_status_i18n as i18n on status.booking_status = i18n.booking_status and i18n.lang_tag = $1
|
left join booking_status_i18n as i18n on status.booking_status = i18n.booking_status and i18n.lang_tag = $1
|
||||||
order by stay desc
|
where (%s)
|
||||||
`, lang)
|
order by lower(stay) desc
|
||||||
|
, booking_id desc
|
||||||
|
LIMIT %d
|
||||||
|
`, where, filters.perPage+1), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -167,7 +181,7 @@ func collectBookingEntries(ctx context.Context, conn *database.Conn, lang langua
|
||||||
var entries []*bookingEntry
|
var entries []*bookingEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
entry := &bookingEntry{}
|
entry := &bookingEntry{}
|
||||||
if err = rows.Scan(&entry.Reference, &entry.URL, &entry.ArrivalDate, &entry.DepartureDate, &entry.HolderName, &entry.Status, &entry.StatusLabel); err != nil {
|
if err = rows.Scan(&entry.ID, &entry.Reference, &entry.URL, &entry.ArrivalDate, &entry.DepartureDate, &entry.HolderName, &entry.Status, &entry.StatusLabel); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
|
@ -177,6 +191,7 @@ func collectBookingEntries(ctx context.Context, conn *database.Conn, lang langua
|
||||||
}
|
}
|
||||||
|
|
||||||
type bookingEntry struct {
|
type bookingEntry struct {
|
||||||
|
ID int
|
||||||
Reference string
|
Reference string
|
||||||
URL string
|
URL string
|
||||||
ArrivalDate time.Time
|
ArrivalDate time.Time
|
||||||
|
@ -186,7 +201,10 @@ type bookingEntry struct {
|
||||||
StatusLabel string
|
StatusLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
type bookingIndex []*bookingEntry
|
type bookingIndex struct {
|
||||||
|
Bookings []*bookingEntry
|
||||||
|
Filters *filterForm
|
||||||
|
}
|
||||||
|
|
||||||
func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
|
func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
|
||||||
switch r.URL.Query().Get("format") {
|
switch r.URL.Query().Get("format") {
|
||||||
|
@ -198,7 +216,7 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
|
||||||
"Holder Name",
|
"Holder Name",
|
||||||
"Status",
|
"Status",
|
||||||
}
|
}
|
||||||
table, err := ods.WriteTable(page, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error {
|
table, err := ods.WriteTable(page.Bookings, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error {
|
||||||
if err := ods.WriteCellString(sb, entry.Reference); err != nil {
|
if err := ods.WriteCellString(sb, entry.Reference); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -217,10 +235,127 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
|
||||||
}
|
}
|
||||||
ods.MustWriteResponse(w, table, user.Locale.Pgettext("bookings.ods", "filename"))
|
ods.MustWriteResponse(w, table, user.Locale.Pgettext("bookings.ods", "filename"))
|
||||||
default:
|
default:
|
||||||
template.MustRenderAdmin(w, r, user, company, "booking/index.gohtml", page)
|
if httplib.IsHTMxRequest(r) && page.Filters.pagination {
|
||||||
|
template.MustRenderAdminNoLayout(w, r, user, company, "booking/results.gohtml", page)
|
||||||
|
} else {
|
||||||
|
template.MustRenderAdminFiles(w, r, user, company, page, "booking/index.gohtml", "booking/results.gohtml")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type filterForm struct {
|
||||||
|
locale *locale.Locale
|
||||||
|
company *auth.Company
|
||||||
|
perPage int
|
||||||
|
pagination bool
|
||||||
|
HolderName *form.Input
|
||||||
|
BookingStatus *form.Select
|
||||||
|
FromDate *form.Input
|
||||||
|
ToDate *form.Input
|
||||||
|
Cursor *form.Input
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *filterForm {
|
||||||
|
return &filterForm{
|
||||||
|
locale: locale,
|
||||||
|
company: company,
|
||||||
|
perPage: 25,
|
||||||
|
HolderName: &form.Input{
|
||||||
|
Name: "holder_name",
|
||||||
|
},
|
||||||
|
BookingStatus: &form.Select{
|
||||||
|
Name: "booking_status",
|
||||||
|
Options: mustGetBookingStatusOptions(ctx, conn, locale),
|
||||||
|
},
|
||||||
|
FromDate: &form.Input{
|
||||||
|
Name: "from_date",
|
||||||
|
},
|
||||||
|
ToDate: &form.Input{
|
||||||
|
Name: "to_date",
|
||||||
|
},
|
||||||
|
Cursor: &form.Input{
|
||||||
|
Name: "cursor",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGetBookingStatusOptions(ctx context.Context, conn *database.Conn, locale *locale.Locale) []*form.Option {
|
||||||
|
return form.MustGetOptions(ctx, conn, `
|
||||||
|
select booking_status.booking_status
|
||||||
|
, isi18n.name
|
||||||
|
from booking_status
|
||||||
|
join booking_status_i18n isi18n using(booking_status)
|
||||||
|
where isi18n.lang_tag = $1
|
||||||
|
order by booking_status`, locale.Language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterForm) Parse(r *http.Request) error {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.HolderName.FillValue(r)
|
||||||
|
f.BookingStatus.FillValue(r)
|
||||||
|
f.FromDate.FillValue(r)
|
||||||
|
f.ToDate.FillValue(r)
|
||||||
|
f.Cursor.FillValue(r)
|
||||||
|
f.pagination = f.Cursor.Val != ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
|
||||||
|
var where []string
|
||||||
|
appendWhere := func(expression string, value interface{}) {
|
||||||
|
args = append(args, value)
|
||||||
|
where = append(where, fmt.Sprintf(expression, len(args)))
|
||||||
|
}
|
||||||
|
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
|
||||||
|
if value != "" {
|
||||||
|
if conv == nil {
|
||||||
|
appendWhere(expression, value)
|
||||||
|
} else {
|
||||||
|
appendWhere(expression, conv(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendWhere("booking.company_id = $%d", f.company.ID)
|
||||||
|
maybeAppendWhere("booking.holder_name ILIKE $%d", f.HolderName.Val, func(v string) interface{} {
|
||||||
|
return "%" + v + "%"
|
||||||
|
})
|
||||||
|
maybeAppendWhere("booking.booking_status = $%d", f.BookingStatus.String(), nil)
|
||||||
|
maybeAppendWhere("lower(stay) >= $%d", f.FromDate.Val, nil)
|
||||||
|
maybeAppendWhere("lower(stay) <= $%d", f.ToDate.Val, nil)
|
||||||
|
|
||||||
|
if f.Cursor.Val != "" {
|
||||||
|
params := strings.Split(f.Cursor.Val, ";")
|
||||||
|
if len(params) == 2 {
|
||||||
|
where = append(where, fmt.Sprintf("(lower(stay), booking_id) < ($%d, $%d)", len(args)+1, len(args)+2))
|
||||||
|
args = append(args, params[0])
|
||||||
|
args = append(args, params[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(where, ") AND ("), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterForm) buildCursor(bookings []*bookingEntry) []*bookingEntry {
|
||||||
|
if len(bookings) <= f.perPage {
|
||||||
|
f.Cursor.Val = ""
|
||||||
|
return bookings
|
||||||
|
}
|
||||||
|
bookings = bookings[:f.perPage]
|
||||||
|
last := bookings[f.perPage-1]
|
||||||
|
f.Cursor.Val = fmt.Sprintf("%s;%d", last.ArrivalDate.Format(database.ISODateFormat), last.ID)
|
||||||
|
return bookings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterForm) HasValue() bool {
|
||||||
|
return f.HolderName.Val != "" ||
|
||||||
|
(len(f.BookingStatus.Selected) > 0 && f.BookingStatus.Selected[0] != "") ||
|
||||||
|
f.FromDate.Val != "" ||
|
||||||
|
f.ToDate.Val != ""
|
||||||
|
}
|
||||||
|
|
||||||
type adminBookingForm struct {
|
type adminBookingForm struct {
|
||||||
*bookingForm
|
*bookingForm
|
||||||
ID int
|
ID int
|
||||||
|
|
|
@ -13,8 +13,60 @@
|
||||||
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.bookingIndex*/ -}}
|
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.bookingIndex*/ -}}
|
||||||
<a href="/admin/bookings/new">{{( pgettext "Add Booking" "action" )}}</a>
|
<a href="/admin/bookings/new">{{( pgettext "Add Booking" "action" )}}</a>
|
||||||
<a href="/admin/bookings?format=ods">{{( pgettext "Export Bookings" "action" )}}</a>
|
<a href="/admin/bookings?format=ods">{{( pgettext "Export Bookings" "action" )}}</a>
|
||||||
|
{{ template "filters-toggle" }}
|
||||||
|
<form class="filters" method="GET" action="/admin/bookings"
|
||||||
|
data-hx-target="main" data-hx-boost="true" data-hx-trigger="change,search,submit"
|
||||||
|
aria-labelledby="filters-toggle"
|
||||||
|
>
|
||||||
|
{{ with .Filters }}
|
||||||
|
<fieldset>
|
||||||
|
{{ with .HolderName -}}
|
||||||
|
<label>
|
||||||
|
{{( pgettext "Holder name" "input" )}}<br>
|
||||||
|
<input type="text"
|
||||||
|
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
|
||||||
|
><br>
|
||||||
|
{{ template "error-message" . }}
|
||||||
|
</label>
|
||||||
|
{{- end }}
|
||||||
|
{{ with .BookingStatus -}}
|
||||||
|
<label>
|
||||||
|
{{( pgettext "Booking status" "input" )}}<br>
|
||||||
|
<select name="{{ .Name }}"
|
||||||
|
{{ template "error-attrs" . }}
|
||||||
|
>
|
||||||
|
<option value="">{{( gettext "All statuses" )}}</option>
|
||||||
|
{{ template "list-options" . }}
|
||||||
|
</select><br>
|
||||||
|
{{ template "error-message" . }}
|
||||||
|
</label>
|
||||||
|
{{- end }}
|
||||||
|
{{ with .FromDate -}}
|
||||||
|
<label>
|
||||||
|
{{( pgettext "From date" "input" )}}<br>
|
||||||
|
<input type="date"
|
||||||
|
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
|
||||||
|
><br>
|
||||||
|
{{ template "error-message" . }}
|
||||||
|
</label>
|
||||||
|
{{- end }}
|
||||||
|
{{ with .ToDate -}}
|
||||||
|
<label>
|
||||||
|
{{( pgettext "To date" "input" )}}<br>
|
||||||
|
<input type="date"
|
||||||
|
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
|
||||||
|
><br>
|
||||||
|
{{ template "error-message" . }}
|
||||||
|
</label>
|
||||||
|
{{- end }}
|
||||||
|
</fieldset>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Filters.HasValue }}
|
||||||
|
<a href="/admin/bookings" class="button">{{( pgettext "Reset" "action" )}}</a>
|
||||||
|
{{ end }}
|
||||||
|
</form>
|
||||||
<h2>{{( pgettext "Bookings" "title" )}}</h2>
|
<h2>{{( pgettext "Bookings" "title" )}}</h2>
|
||||||
{{ if . -}}
|
{{ if .Bookings -}}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -26,15 +78,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{ range . -}}
|
{{ template "results.gohtml" . }}
|
||||||
<tr class="booking-{{ .Status }}">
|
|
||||||
<td><a href="{{ .URL }}">{{ .Reference }}</a></td>
|
|
||||||
<td>{{ .ArrivalDate | formatDate }}</td>
|
|
||||||
<td>{{ .DepartureDate | formatDate }}</td>
|
|
||||||
<td>{{ .HolderName }}</td>
|
|
||||||
<td class="booking-status">{{ .StatusLabel }}</td>
|
|
||||||
</tr>
|
|
||||||
{{- end }}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{ else -}}
|
{{ else -}}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
{{ range .Bookings -}}
|
||||||
|
<tr class="booking-{{ .Status }}">
|
||||||
|
<td><a href="{{ .URL }}">{{ .Reference }}</a></td>
|
||||||
|
<td>{{ .ArrivalDate | formatDate }}</td>
|
||||||
|
<td>{{ .DepartureDate | formatDate }}</td>
|
||||||
|
<td>{{ .HolderName }}</td>
|
||||||
|
<td class="booking-status">{{ .StatusLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
{{- end }}
|
||||||
|
{{ if .Filters.Cursor.Val }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
{{ with .Filters -}}
|
||||||
|
<form data-hx-get="/admin/bookings" data-hx-target="closest tr" data-hx-swap="outerHTML">
|
||||||
|
{{ with .HolderName -}}<input type="hidden" name="{{ .Name }}"
|
||||||
|
value="{{ .Val }}">{{- end }}
|
||||||
|
{{ with .BookingStatus -}}<input type="hidden" name="{{ .Name }}"
|
||||||
|
value="{{ .String }}">{{- end }}
|
||||||
|
{{ with .FromDate -}}<input type="hidden" name="{{ .Name }}"
|
||||||
|
value="{{ .Val }}">{{- end }}
|
||||||
|
{{ with .ToDate -}}<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">{{- end }}
|
||||||
|
{{ with .Cursor -}}<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">{{- end }}
|
||||||
|
<button type="submit">{{( pgettext "Load more" "action" )}}</button>
|
||||||
|
</form>
|
||||||
|
{{- end }}
|
||||||
|
<td>
|
||||||
|
</tr>
|
||||||
|
{{- end }}
|
Loading…
Reference in New Issue