Add filtering and pagination for bookings

This commit is contained in:
jordi fita mas 2024-05-03 00:28:48 +02:00
parent b2ee4dfea3
commit 5d4fe15e88
3 changed files with 228 additions and 21 deletions

View File

@ -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,8 +235,125 @@ 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 {

View File

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

View File

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