“Mockup” for the new booking form

It does nothing but compute the total of a booking, much like it does
for guests.  In fact, i use the same payment relations to do the exact
same computation, otherwise i am afraid i will make a mistake in the
ACSI or such, now or in future version; better if both are exactly the
same.

The idea is that once the user creates the booking, i will delete that
payment, because it makes no sense to keep it in this case; nobody is
going to pay for it.

Had to reuse the grid showing the bookings of campsites because
employees need to select one or more campsites to book, and need to see
which are available.  In this case, i have to filter by campsite type
and use the arrival and departure dates to filter the months, now up to
the day, not just month.

Had to change max width of th and td in the grid to take into account
that now a month could have a single day, for instance, and the month
heading can not stretch the day or booking spans would not be in their
correct positions.

For that, i needed to access campsiteEntry, bookingEntry, and Month from
campsite package, but campsite imports campsite/types, and
campsite/types already imports booking for the BookingDates type.  To
break the cycle, had to move all that to booking and use from campsite;
it is mostly unchanged, except for the granularity of dates up to days
instead of just months.

The design of this form calls for a different way of showing the totals,
because here employees have to see the amount next to the input with
the units, instead of having a footer with the table.  I did not like
the idea of having to query the database for that, therefore i “lifter”
the payment draft into a struct that both public and admin forms use
to show they respective views of the cart.
This commit is contained in:
jordi fita mas 2024-04-23 21:07:41 +02:00
parent 598354e8b7
commit 3aa53cf1a9
11 changed files with 799 additions and 284 deletions

View File

@ -7,14 +7,16 @@ package booking
import ( import (
"context" "context"
"golang.org/x/text/language"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
) )
@ -38,6 +40,17 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet)
} }
case "new":
switch r.Method {
case http.MethodGet:
f, err := newAdminBookingForm(r, conn, company, user.Locale)
if err != nil {
panic(err)
}
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -56,7 +69,7 @@ func serveBookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User,
func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag) ([]*bookingEntry, error) { func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag) ([]*bookingEntry, error) {
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, `
select left(slug::text, 10) select left(slug::text, 10)
, '/admin/booking/' || slug , '/admin/bookings/' || slug
, lower(stay) , lower(stay)
, upper(stay) , upper(stay)
, holder_name , holder_name
@ -128,3 +141,59 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
template.MustRenderAdmin(w, r, user, company, "booking/index.gohtml", page) template.MustRenderAdmin(w, r, user, company, "booking/index.gohtml", page)
} }
} }
type adminBookingForm struct {
*bookingForm
ID int
Campsites []*CampsiteEntry
Months []*Month
}
func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Company, l *locale.Locale) (*adminBookingForm, error) {
form, err := newBookingForm(r, company, conn, l)
if err != nil {
return nil, err
}
if form.Options != nil {
for _, option := range form.Options.Options {
option.Subtotal = findSubtotal(option.ID, form.Cart)
}
}
f := &adminBookingForm{
bookingForm: form,
}
// Dates and Campsite are valid
if form.Guests != nil {
arrivalDate, _ := time.Parse(database.ISODateFormat, form.Dates.ArrivalDate.Val)
from := arrivalDate.AddDate(0, 0, -1)
departureDate, _ := time.Parse(database.ISODateFormat, form.Dates.DepartureDate.Val)
to := departureDate.AddDate(0, 0, 2)
f.Months = CollectMonths(from, to)
f.Campsites, err = CollectCampsiteEntries(r.Context(), company, conn, from, to, form.CampsiteType.String())
if err != nil {
return nil, err
}
}
return f, nil
}
func findSubtotal(ID int, cart *bookingCart) string {
none := "0.0"
if cart == nil || cart.Draft == nil {
return none
}
for _, option := range cart.Draft.Options {
if option.ID == ID {
return option.Subtotal
}
}
return none
}
func (f *adminBookingForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) {
template.MustRenderAdminNoLayoutFiles(w, r, user, company, f, "booking/fields.gohtml", "booking/grid.gohtml")
} else {
template.MustRenderAdminFiles(w, r, user, company, f, "booking/form.gohtml", "booking/fields.gohtml", "booking/grid.gohtml")
}
}

155
pkg/booking/campsite.go Normal file
View File

@ -0,0 +1,155 @@
package booking
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"time"
"dev.tandem.ws/tandem/camper/pkg/season"
)
type Month struct {
Year int
Month time.Month
Name string
Days []time.Time
Spans []*Span
}
type Span struct {
Weekend bool
Today bool
Count int
}
func isWeekend(t time.Time) bool {
switch t.Weekday() {
case time.Saturday, time.Sunday:
return true
default:
return false
}
}
func CollectMonths(from time.Time, to time.Time) []*Month {
current := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
var months []*Month
for !current.Equal(to) {
span := &Span{
Weekend: isWeekend(current),
Today: current.Equal(today),
}
month := &Month{
Year: current.Year(),
Month: current.Month(),
Name: season.LongMonthNames[current.Month()-1],
Days: make([]time.Time, 0, 31),
Spans: make([]*Span, 0, 10),
}
month.Spans = append(month.Spans, span)
for current.Month() == month.Month && !current.Equal(to) {
month.Days = append(month.Days, current)
if span.Weekend != isWeekend(current) || span.Today != current.Equal(today) {
span = &Span{
Weekend: isWeekend(current),
Today: current.Equal(today),
}
month.Spans = append(month.Spans, span)
}
span.Count = span.Count + 1
current = current.AddDate(0, 0, 1)
}
months = append(months, month)
}
return months
}
type CampsiteEntry struct {
Label string
Type string
Active bool
Bookings map[time.Time]*CampsiteBooking
}
type CampsiteBooking struct {
Holder string
Status string
Nights int
Begin bool
End bool
}
func CollectCampsiteEntries(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsiteType string) ([]*CampsiteEntry, error) {
rows, err := conn.Query(ctx, `
select campsite.label
, campsite_type.name
, campsite.active
from campsite
join campsite_type using (campsite_type_id)
where campsite.company_id = $1
and ($2::uuid is null or campsite_type.slug = $2::uuid)
order by label`, company.ID, database.ZeroNullUUID(campsiteType))
if err != nil {
return nil, err
}
defer rows.Close()
byLabel := make(map[string]*CampsiteEntry)
var campsites []*CampsiteEntry
for rows.Next() {
entry := &CampsiteEntry{}
if err = rows.Scan(&entry.Label, &entry.Type, &entry.Active); err != nil {
return nil, err
}
campsites = append(campsites, entry)
byLabel[entry.Label] = entry
}
if err := collectCampsiteBookings(ctx, company, conn, from, to, byLabel); err != nil {
return nil, err
}
return campsites, nil
}
func collectCampsiteBookings(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsites map[string]*CampsiteEntry) error {
rows, err := conn.Query(ctx, `
select campsite.label
, lower(booking_campsite.stay * daterange($2::date, $3::date))
, holder_name
, booking_status
, upper(booking_campsite.stay * daterange($2::date, $3::date)) - lower(booking_campsite.stay * daterange($2::date, $3::date))
, booking_campsite.stay &> daterange($2::date, $3::date)
, booking_campsite.stay &< daterange($2::date, ($3 - 1)::date)
from booking_campsite
join booking using (booking_id)
join campsite using (campsite_id)
where booking.company_id = $1
and booking_campsite.stay && daterange($2::date, $3::date)
order by label`, company.ID, from, to)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
entry := &CampsiteBooking{}
var label string
var date time.Time
if err = rows.Scan(&label, &date, &entry.Holder, &entry.Status, &entry.Nights, &entry.Begin, &entry.End); err != nil {
return err
}
campsite := campsites[label]
if campsite != nil {
if campsite.Bookings == nil {
campsite.Bookings = make(map[time.Time]*CampsiteBooking)
}
campsite.Bookings[date] = entry
}
}
return nil
}

View File

@ -10,6 +10,7 @@ import (
) )
type bookingCart struct { type bookingCart struct {
Draft *paymentDraft
Lines []*cartLine Lines []*cartLine
Total string Total string
DownPayment string DownPayment string
@ -23,52 +24,80 @@ type cartLine struct {
Subtotal string Subtotal string
} }
func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*bookingCart, error) { type paymentDraft struct {
cart := &bookingCart{ ArrivalDate time.Time
Total: "0.0", DepartureDate time.Time
NumAdults int
NumTeenagers int
NumChildren int
NumDogs int
ZonePreferences string
ACSICard bool
PaymentID int
NumNights int
Nights string
Adults string
Teenagers string
Children string
Dogs string
TouristTax string
Total string
DownPaymentPercent int
DownPayment string
Options []*paymentOption
} }
type paymentOption struct {
ID int
Label string
Units int
Subtotal string
}
func draftPayment(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*paymentDraft, error) {
var err error
if f.Dates == nil { if f.Dates == nil {
return cart, nil return nil, nil
} }
arrivalDate, err := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
draft := &paymentDraft{}
draft.ArrivalDate, err = time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
if err != nil { if err != nil {
return cart, nil return nil, nil
} }
departureDate, err := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val) draft.DepartureDate, err = time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val)
if err != nil { if err != nil {
return cart, nil return nil, nil
} }
if f.Guests == nil { if f.Guests == nil {
return cart, nil return nil, nil
} }
numAdults, err := strconv.Atoi(f.Guests.NumberAdults.Val) draft.NumAdults, err = strconv.Atoi(f.Guests.NumberAdults.Val)
if err != nil { if err != nil {
return cart, nil return nil, nil
} }
numTeenagers, err := strconv.Atoi(f.Guests.NumberTeenagers.Val) draft.NumTeenagers, err = strconv.Atoi(f.Guests.NumberTeenagers.Val)
if err != nil { if err != nil {
return cart, nil return nil, nil
} }
numChildren, err := strconv.Atoi(f.Guests.NumberChildren.Val) draft.NumChildren, err = strconv.Atoi(f.Guests.NumberChildren.Val)
if err != nil { if err != nil {
return cart, nil return nil, nil
} }
numDogs := 0
if f.Guests.NumberDogs != nil { if f.Guests.NumberDogs != nil {
numDogs, err = strconv.Atoi(f.Guests.NumberDogs.Val) draft.NumDogs, err = strconv.Atoi(f.Guests.NumberDogs.Val)
if err != nil { if err != nil {
return cart, nil return nil, nil
} }
} }
zonePreferences := ""
if f.Options != nil && f.Options.ZonePreferences != nil {
zonePreferences = f.Options.ZonePreferences.Val
}
var ACSICard bool if f.Options != nil && f.Options.ZonePreferences != nil {
draft.ZonePreferences = f.Options.ZonePreferences.Val
}
if f.Guests.ACSICard != nil { if f.Guests.ACSICard != nil {
ACSICard = f.Guests.ACSICard.Checked draft.ACSICard = f.Guests.ACSICard.Checked
} }
optionMap := make(map[int]*campsiteTypeOption) optionMap := make(map[int]*campsiteTypeOption)
@ -106,62 +135,37 @@ func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, ca
join currency using (currency_code) join currency using (currency_code)
`, `,
database.ZeroNullUUID(f.PaymentSlug.Val), database.ZeroNullUUID(f.PaymentSlug.Val),
arrivalDate, draft.ArrivalDate,
departureDate, draft.DepartureDate,
campsiteType, campsiteType,
numAdults, draft.NumAdults,
numTeenagers, draft.NumTeenagers,
numChildren, draft.NumChildren,
numDogs, draft.NumDogs,
zonePreferences, draft.ZonePreferences,
ACSICard, draft.ACSICard,
database.OptionUnitsArray(optionUnits), database.OptionUnitsArray(optionUnits),
) )
var paymentID int
var numNights int
var nights string
var adults string
var teenagers string
var children string
var dogs string
var touristTax string
var total string
var downPayment string
if err = row.Scan( if err = row.Scan(
&f.PaymentSlug.Val, &f.PaymentSlug.Val,
&paymentID, &draft.PaymentID,
&numNights, &draft.NumNights,
&nights, &draft.Nights,
&adults, &draft.Adults,
&teenagers, &draft.Teenagers,
&children, &draft.Children,
&dogs, &draft.Dogs,
&touristTax, &draft.TouristTax,
&total, &draft.Total,
&downPayment, &draft.DownPayment,
&cart.DownPaymentPercent, &draft.DownPaymentPercent,
); err != nil { ); err != nil {
if database.ErrorIsNotFound(err) { if database.ErrorIsNotFound(err) {
return cart, nil return nil, nil
} }
return nil, err return nil, err
} }
maybeAddLine := func(units int, subtotal string, concept string) {
if units > 0 && subtotal != "" {
cart.Lines = append(cart.Lines, &cartLine{
Concept: concept,
Units: units,
Subtotal: subtotal,
})
}
}
maybeAddLine(numNights, nights, locale.PgettextNoop("Night", "cart"))
maybeAddLine(numAdults, adults, locale.PgettextNoop("Adult", "cart"))
maybeAddLine(numTeenagers, teenagers, locale.PgettextNoop("Teenager", "cart"))
maybeAddLine(numChildren, children, locale.PgettextNoop("Child", "cart"))
maybeAddLine(numDogs, dogs, locale.PgettextNoop("Dog", "cart"))
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, `
select campsite_type_option_id select campsite_type_option_id
, units , units
@ -170,7 +174,8 @@ func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, ca
join payment using (payment_id) join payment using (payment_id)
join currency using (currency_code) join currency using (currency_code)
where payment_id = $1 where payment_id = $1
`, paymentID) order by campsite_type_option_id
`, draft.PaymentID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -188,20 +193,62 @@ func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, ca
if option == nil { if option == nil {
continue continue
} }
maybeAddLine(units, subtotal, option.Label) draft.Options = append(draft.Options, &paymentOption{
ID: option.ID,
Label: option.Label,
Units: units,
Subtotal: subtotal,
})
} }
if rows.Err() != nil { if rows.Err() != nil {
return nil, rows.Err() return nil, rows.Err()
} }
maybeAddLine(numAdults, touristTax, locale.PgettextNoop("Tourist tax", "cart")) return draft, nil
}
if total != "0.0" { func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*bookingCart, error) {
cart.Total = total cart := &bookingCart{
Total: "0.0",
}
draft, err := draftPayment(ctx, conn, f, campsiteType)
if err != nil {
return nil, err
}
if draft == nil {
return cart, nil
}
cart.Draft = draft
cart.DownPaymentPercent = draft.DownPaymentPercent
maybeAddLine := func(units int, subtotal string, concept string) {
if units > 0 && subtotal != "" {
cart.Lines = append(cart.Lines, &cartLine{
Concept: concept,
Units: units,
Subtotal: subtotal,
})
}
}
maybeAddLine(draft.NumNights, draft.Nights, locale.PgettextNoop("Night", "cart"))
maybeAddLine(draft.NumAdults, draft.Adults, locale.PgettextNoop("Adult", "cart"))
maybeAddLine(draft.NumTeenagers, draft.Teenagers, locale.PgettextNoop("Teenager", "cart"))
maybeAddLine(draft.NumChildren, draft.Children, locale.PgettextNoop("Child", "cart"))
maybeAddLine(draft.NumDogs, draft.Dogs, locale.PgettextNoop("Dog", "cart"))
for _, option := range draft.Options {
maybeAddLine(option.Units, option.Subtotal, option.Label)
}
maybeAddLine(draft.NumAdults, draft.TouristTax, locale.PgettextNoop("Tourist tax", "cart"))
if draft.Total != "0.0" {
cart.Total = draft.Total
cart.Enabled = f.Guests.Error == nil cart.Enabled = f.Guests.Error == nil
if downPayment != total { if draft.DownPayment != draft.Total {
cart.DownPayment = downPayment cart.DownPayment = draft.DownPayment
} }
} }

View File

@ -135,6 +135,7 @@ type campsiteTypeOption struct {
Min int Min int
Max int Max int
Input *form.Input Input *form.Input
Subtotal string
} }
type bookingCustomerFields struct { type bookingCustomerFields struct {

View File

@ -13,12 +13,12 @@ import (
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/booking"
"dev.tandem.ws/tandem/camper/pkg/campsite/types" "dev.tandem.ws/tandem/camper/pkg/campsite/types"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form" "dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/season"
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
) )
@ -95,106 +95,21 @@ func serveCampsiteIndex(w http.ResponseWriter, r *http.Request, user *auth.User,
panic(err) panic(err)
} }
var err error var err error
page.Campsites, err = collectCampsiteEntries(r.Context(), company, conn, page.From.Date(), page.To.Date()) from := page.From.Date()
to := page.To.Date().AddDate(0, 1, 0)
page.Campsites, err = booking.CollectCampsiteEntries(r.Context(), company, conn, from, to, "")
if err != nil { if err != nil {
panic(err) panic(err)
} }
page.Months = collectMonths(page.From.Date(), page.To.Date()) page.Months = booking.CollectMonths(from, to)
page.MustRender(w, r, user, company) page.MustRender(w, r, user, company)
} }
func collectCampsiteEntries(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time) ([]*campsiteEntry, error) {
rows, err := conn.Query(ctx, `
select campsite.label
, campsite_type.name
, campsite.active
from campsite
join campsite_type using (campsite_type_id)
where campsite.company_id = $1
order by label`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
byLabel := make(map[string]*campsiteEntry)
var campsites []*campsiteEntry
for rows.Next() {
entry := &campsiteEntry{}
if err = rows.Scan(&entry.Label, &entry.Type, &entry.Active); err != nil {
return nil, err
}
campsites = append(campsites, entry)
byLabel[entry.Label] = entry
}
if err := collectBookingEntries(ctx, company, conn, from, to, byLabel); err != nil {
return nil, err
}
return campsites, nil
}
func collectBookingEntries(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsites map[string]*campsiteEntry) error {
lastDay := to.AddDate(0, 1, 0)
rows, err := conn.Query(ctx, `
select campsite.label
, lower(booking_campsite.stay * daterange($2::date, $3::date))
, holder_name
, booking_status
, upper(booking_campsite.stay * daterange($2::date, $3::date)) - lower(booking_campsite.stay * daterange($2::date, $3::date))
, booking_campsite.stay &> daterange($2::date, $3::date)
, booking_campsite.stay &< daterange($2::date, $3::date)
from booking_campsite
join booking using (booking_id)
join campsite using (campsite_id)
where booking.company_id = $1
and booking_campsite.stay && daterange($2::date, $3::date)
order by label`, company.ID, from, lastDay)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
entry := &bookingEntry{}
var label string
var date time.Time
if err = rows.Scan(&label, &date, &entry.Holder, &entry.Status, &entry.Nights, &entry.Begin, &entry.End); err != nil {
return err
}
campsite := campsites[label]
if campsite != nil {
if campsite.Bookings == nil {
campsite.Bookings = make(map[time.Time]*bookingEntry)
}
campsite.Bookings[date] = entry
}
}
return nil
}
type campsiteEntry struct {
Label string
Type string
Active bool
Bookings map[time.Time]*bookingEntry
}
type bookingEntry struct {
Holder string
Status string
Nights int
Begin bool
End bool
}
type campsiteIndex struct { type campsiteIndex struct {
From *form.Month From *form.Month
To *form.Month To *form.Month
Campsites []*campsiteEntry Campsites []*booking.CampsiteEntry
Months []*Month Months []*booking.Month
} }
func newCampsiteIndex() *campsiteIndex { func newCampsiteIndex() *campsiteIndex {
@ -224,62 +139,8 @@ func (page *campsiteIndex) Parse(r *http.Request) error {
return nil return nil
} }
type Month struct {
Year int
Month time.Month
Name string
Days []time.Time
Spans []*Span
}
type Span struct {
Weekend bool
Count int
}
func isWeekend(t time.Time) bool {
switch t.Weekday() {
case time.Saturday, time.Sunday:
return true
default:
return false
}
}
func collectMonths(from time.Time, to time.Time) []*Month {
current := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.UTC)
numMonths := (to.Year()-from.Year())*12 + int(to.Month()) - int(from.Month()) + 1
var months []*Month
for i := 0; i < numMonths; i++ {
span := &Span{
Weekend: isWeekend(current),
}
month := &Month{
Year: current.Year(),
Month: current.Month(),
Name: season.LongMonthNames[current.Month()-1],
Days: make([]time.Time, 0, 31),
Spans: make([]*Span, 0, 10),
}
month.Spans = append(month.Spans, span)
for current.Month() == month.Month {
month.Days = append(month.Days, current)
if span.Weekend != isWeekend(current) {
span = &Span{
Weekend: !span.Weekend,
}
month.Spans = append(month.Spans, span)
}
span.Count = span.Count + 1
current = current.AddDate(0, 0, 1)
}
months = append(months, month)
}
return months
}
func (page *campsiteIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (page *campsiteIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, page, "campsite/index.gohtml", "web/templates/campground_map.svg") template.MustRenderAdminFiles(w, r, user, company, page, "campsite/index.gohtml", "booking/grid.gohtml")
} }
func addCampsite(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func addCampsite(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {

View File

@ -56,6 +56,10 @@ func MustRenderAdminNoLayout(w io.Writer, r *http.Request, user *auth.User, comp
mustRenderLayout(w, user, company, adminTemplateFile, data, filename) mustRenderLayout(w, user, company, adminTemplateFile, data, filename)
} }
func MustRenderAdminNoLayoutFiles(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, data interface{}, filenames ...string) {
mustRenderLayout(w, user, company, adminTemplateFile, data, filenames...)
}
func MustRenderPublic(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) { func MustRenderPublic(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) {
layout := "layout.gohtml" layout := "layout.gohtml"
mustRenderLayout(w, user, company, publicTemplateFile, data, layout, filename) mustRenderLayout(w, user, company, publicTemplateFile, data, layout, filename)

View File

@ -772,7 +772,7 @@ label[x-show] > span, label[x-show] > br {
/*<editor-fold desc="Campsites Booking">*/ /*<editor-fold desc="Campsites Booking">*/
#campsites-booking { #campsites-booking {
height: 90vh; max-height: 90vh;
overflow: scroll; overflow: scroll;
} }
@ -784,6 +784,10 @@ label[x-show] > span, label[x-show] > br {
background-color: var(--camper--color--rosy); background-color: var(--camper--color--rosy);
} }
#campsites-booking .today {
border-left: 2px solid red;
}
#campsites-booking colgroup { #campsites-booking colgroup {
border-right: 2px solid; border-right: 2px solid;
} }
@ -792,6 +796,7 @@ label[x-show] > span, label[x-show] > br {
border: 1px solid; border: 1px solid;
min-width: 2.25ch; min-width: 2.25ch;
max-width: 2.25ch; max-width: 2.25ch;
width: 2.25ch;
} }
#campsites-booking th { #campsites-booking th {
@ -801,6 +806,9 @@ label[x-show] > span, label[x-show] > br {
#campsites-booking thead tr:first-child th, #campsites-booking tbody th { #campsites-booking thead tr:first-child th, #campsites-booking tbody th {
padding: 0 .5ch; padding: 0 .5ch;
max-width: calc(2.25ch * var(--days));
overflow: hidden;
white-space: nowrap;
} }
#campsites-booking thead tr:last-child th { #campsites-booking thead tr:last-child th {
@ -861,3 +869,52 @@ label[x-show] > span, label[x-show] > br {
} }
/*</editor-fold>*/ /*</editor-fold>*/
/*<editor-fold desc="Booking Form">*/
#booking-form > fieldset {
display: grid;
gap: .5em;
grid-template-columns: repeat(4, 1fr);
}
#booking-form fieldset fieldset, #booking-form .colspan {
grid-column: span 2;
}
#booking-form #campsites-booking {
grid-column: 1 / -1;
}
#booking-form :is(label, fieldset fieldset),
#booking-form .booking-items table {
margin-top: 0;
}
#booking-form :is(input:not([type='checkbox']), select) {
width: 100%;
}
#booking-form :is(.customer-details, .booking-period) {
display: grid;
gap: .5em;
grid-template-columns: 1fr 1fr;
align-content: start;
}
#booking-form .booking-items td {
padding: .3125rem 0;
}
#booking-form .booking-items br {
display: none;
}
#booking-form .booking-items td:first-child input {
width: 6ch;
}
#booking-form h3 {
margin-bottom: 0;
}
/*</editor-fold>*/

View File

@ -0,0 +1,283 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-FileCopyrightText: 2023 Oriol Carbonell <info@oriolcarbonell.cat>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}}
{{ CSRFInput }}
<fieldset>
<input type="hidden" name="{{ .PaymentSlug.Name }}" value="{{ .PaymentSlug.Val }}">
{{ with .CampsiteType -}}
<label>
{{( pgettext "Accommodation" "title" )}}<br>
<select name="{{ .Name }}"
required
data-hx-get="/admin/bookings/new" data-hx-trigger="change"
{{ template "error-attrs" . }}
>
<option value="">{{( gettext "Choose an accomodation" )}}</option>
{{ template "list-options" . }}
</select><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .Dates -}}
<fieldset class="booking-period"
data-hx-get="/admin/bookings/new"
data-hx-trigger="change delay:500ms"
>
{{ with .ArrivalDate -}}
<label>
{{( pgettext "Arrival date" "input" )}}<br>
<input type="date" required
min="{{ formatDateAttr .MinDate }}"
max="{{ formatDateAttr .MaxDate }}"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .DepartureDate -}}
<label>
{{( pgettext "Departure date" "input" )}}<br>
<input type="date" required
min="{{ formatDateAttr .MinDate }}"
max="{{ formatDateAttr .MaxDate }}"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
</fieldset>
{{- end }}
{{ with .Options -}}
{{ with .ZonePreferences -}}
<label>{{( pgettext "Area preferences (optional)" "input" )}}<br>
<input type="text"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
<a href="/{{ currentLocale }}/campground?zones" target="_blank">{{( gettext "Campground map" )}}</a><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{- end }}
{{ with $guests := .Guests -}}
{{ $draft := $.Cart.Draft }}
<fieldset class="booking-items"
data-hx-get="/admin/bookings/new" data-hx-trigger="change"
>
<table>
<thead>
<tr>
<th scope="col">{{( pgettext "Units" "header" )}}</th>
<th scope="col">{{( pgettext "Decription" "header" )}}</th>
<th scope="col" class="numeric">{{( pgettext "Total" "header" )}}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ $draft.NumNights }}</td>
<td>{{( pgettext "Night" "cart" )}}</td>
<td class="numeric">{{ formatPrice $draft.Nights }}</td>
</tr>
{{ with .NumberAdults -}}
<tr>
<td><input id="adults" type="number" required
name="{{ .Name }}" value="{{ .Val }}"
min="1"{{if not $guests.OverflowAllowed }} max="{{ $guests.MaxGuests }}"{{ end }}
{{ template "error-attrs" . }}
>
</td>
<td>
<label for="adults">{{( pgettext "Adults aged 17 or older" "input" )}}</label><br>
{{ template "error-message" . }}
</td>
<td class="numeric">{{ formatPrice $draft.Adults }}</td>
</tr>
{{- end }}
{{ with .NumberTeenagers -}}
<tr>
<td>
<input id="teenagers" type="number" required
name="{{ .Name }}" value="{{ .Val }}"
min="0"{{if not $guests.OverflowAllowed }} max="{{ $guests.MaxGuests | dec }}"{{ end }}
{{ template "error-attrs" . }}
>
</td>
<td>
<label for="teenagers">{{( pgettext "Teenagers from 11 to 16 years old" "input" )}}</label><br>
{{ template "error-message" . }}
</td>
<td class="numeric">{{ formatPrice $draft.Teenagers }}</td>
</tr>
{{- end }}
{{ with .NumberChildren -}}
<tr>
<td>
<input id="children" type="number" required
name="{{ .Name }}" value="{{ .Val }}"
min="0"{{if not $guests.OverflowAllowed }} max="{{ $guests.MaxGuests | dec }}"{{ end }}
{{ template "error-attrs" . }}
>
</td>
<td>
<label for="children">{{( pgettext "Children from 2 to 10 years old" "input" )}}</label><br>
{{ template "error-message" . }}
</td>
<td class="numeric">{{ formatPrice $draft.Children }}</td>
</tr>
{{- end }}
{{ with .NumberDogs -}}
<tr>
<td>
<input id="dogs" type="number" required
name="{{ .Name }}" value="{{ .Val }}" min="0"
{{ template "error-attrs" . }}
>
</td>
<td>
<label for="dogs">{{( pgettext "Dogs" "input" )}}</label><br>
{{ template "error-message" . }}
</td>
<td class="numeric">{{ formatPrice $draft.Dogs }}</td>
</tr>
{{- end }}
{{ with $.Options -}}
{{ range .Options -}}
<tr>
<td>
<input id="{{ .Input.Name }}" type="number" required
name="{{ .Input.Name }}" value="{{ .Input.Val }}"
min="{{ .Min }}" max="{{ .Max }}"
{{ template "error-attrs" .Input }}
>
</td>
<td>
<label for="{{ .Input.Name }}">{{ .Label }}</label><br>
{{ template "error-message" .Input }}
</td>
<td class="numeric">{{ formatPrice .Subtotal }}</td>
</tr>
{{- end }}
{{- end }}
<tr>
<td>{{ $draft.NumAdults }}</td>
<td>{{( pgettext "Tourist tax" "cart" )}}</td>
<td class="numeric">{{ formatPrice $draft.TouristTax }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="2" class="numeric">{{( pgettext "Total" "header" )}}</th>
<td class="numeric">{{ formatPrice $draft.Total }}</td>
</tr>
</tfoot>
</table>
{{ if not .NumberDogs -}}
<small>{{( gettext "Note: This accommodation does <strong>not</strong> allow dogs.") | raw }}</small>
{{- end }}
{{ if .Error -}}
<p class="error">{{ .Error }}</p>
{{- end }}
</fieldset>
{{- end }}
{{ with .Customer -}}
<fieldset class="customer-details">
<legend>{{( pgettext "Customer Details" "title" )}}</legend>
{{ with .FullName -}}
<label>
{{( pgettext "Full name" "input" )}}<br>
<input type="text" required minlength="2"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .Country -}}
<label>
{{( pgettext "Country" "input" )}}<br>
<select name="{{ .Name }}"
required
{{ template "error-attrs" . }}
>
<option>{{( gettext "Choose a country" )}}</option>
{{ template "list-options" . }}
</select><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .Address -}}
<label class="colspan">
{{( pgettext "Address" "input" )}}<br>
<input type="text" required
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .PostalCode -}}
<label>
{{( pgettext "Postcode" "input" )}}<br>
<input type="text" required
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .City -}}
<label>
{{( pgettext "Town or village" "input" )}}<br>
<input type="text"
required
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .Email -}}
<label>
{{( pgettext "Email" "input" )}}<br>
<input type="email" required
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .Phone -}}
<label>
{{( pgettext "Phone" "input" )}}<br>
<input type="tel" required
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with $.Guests.ACSICard -}}
<label class="colspan" data-hx-get="/admin/bookings/new" data-hx-trigger="change">
<input type="checkbox" name="{{ .Name }}" {{ if .Checked}}checked{{ end }}
{{ template "error-attrs" . }}
> {{( pgettext "ACSI card? (optional)" "input" )}}<br>
{{ template "error-message" . }}
</label>
{{- end }}
</fieldset>
{{- end }}
{{ if .Campsites -}}
<h3>{{( pgettext "Campsites" "title" )}}</h3>
{{ template "grid.gohtml" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">
{{- if .ID -}}
{{( pgettext "Update" "action" )}}
{{- else -}}
{{( pgettext "Add" "action" )}}
{{- end -}}
</button>
</footer>
{{ define "campsite-heading" -}}
<label><input type="checkbox"> {{ .Label }}</label>
{{- end }}

View File

@ -0,0 +1,33 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}}
{{ if .ID }}
{{( pgettext "Edit Booking" "title" )}}
{{ else }}
{{( pgettext "New Booking" "title" )}}
{{ end }}
{{- end }}
{{ define "breadcrumb" -}}
<li><a href="./">{{( pgettext "Bookings" "title" )}}</a></li>
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}}
<h2>{{ template "title" .}}</h2>
<form id="booking-form"
data-hx-ext="morph"
data-hx-swap="morph:innerHTML"
data-hx-include="this"
data-hx-target="this"
data-hx-replace-url="true"
{{- if .ID }} data-hx-put="/admin/bookings/{{ .CurrentLabel }}"
{{- else }} action="/admin/bookings" method="post"
{{- end -}}
>
{{ template "fields.gohtml" . }}
</form>
{{- end }}

View File

@ -0,0 +1,50 @@
<div id="campsites-booking">
<table>
<colgroup></colgroup>
{{ range .Months }}
<colgroup>
{{ range .Spans }}
<col span="{{ .Count }}" class="{{ if .Today}}today {{ end }}{{ if .Weekend }}weekend{{ else }}weekday{{ end }}">
{{- end }}
</colgroup>
{{- end }}
<thead>
<tr>
<th scope="col" rowspan="2">{{( pgettext "Label" "header" )}}</th>
{{ range .Months }}
<th scope="col" style="--days: {{ len .Days }}" colspan="{{ len .Days }}">{{ pgettext .Name "month" }} {{ .Year }}</th>
{{- end }}
</tr>
<tr>
{{ range .Months }}
{{ range .Days }}
<th scope="col">{{ .Day }}</th>
{{- end }}
{{- end }}
</tr>
</thead>
<tbody>
{{ range $campsite := .Campsites -}}
<tr>
<th scope="row">
{{ template "campsite-heading" . }}
</th>
{{ range $.Months }}
{{ range $day := .Days }}
{{ with index $campsite.Bookings $day -}}
<td class="booking-{{ .Status }}">
<div class="booking-status"
style="--booking-nights: {{ .Nights }}; --booking-begin: {{ if .Begin }}1{{ else }}0{{ end }}; --booking-end: {{ if .End }}1{{ else }}0{{ end }}"
title="{{ .Holder }}"
>{{ .Holder }}</div>
</td>
{{- else -}}
<td></td>
{{- end }}
{{- end }}
{{- end }}
</tr>
{{- end }}
</tbody>
</table>
</div>

View File

@ -17,60 +17,7 @@
<h2>{{( pgettext "Campsites" "title" )}}</h2> <h2>{{( pgettext "Campsites" "title" )}}</h2>
{{ if .Campsites -}} {{ if .Campsites -}}
<div id="campsites-booking"> {{ template "grid.gohtml" . }}
<table>
<colgroup></colgroup>
{{ range .Months }}
<colgroup>
{{ range .Spans }}
<col span="{{ .Count }}" class="{{ if .Weekend }}weekend{{ else }}weekday{{ end }}">
{{- end }}
</colgroup>
{{- end }}
<thead>
<tr>
<th scope="col" rowspan="2">{{( pgettext "Label" "header" )}}</th>
{{ range .Months }}
<th scope="col" colspan="{{ len .Days }}">{{ pgettext .Name "month" }} {{ .Year }}</th>
{{- end }}
</tr>
<tr>
{{ range .Months }}
{{ range .Days }}
<th scope="col">{{ .Day }}</th>
{{- end }}
{{- end }}
</tr>
</thead>
<tbody>
{{ range $campsite := .Campsites -}}
<tr>
<th scope="row">
{{- if isAdmin -}}
<a href="/admin/campsites/{{ .Label }}">{{ .Label }}</a>
{{- else -}}
{{ .Label }}
{{- end -}}
</th>
{{ range $.Months }}
{{ range $day := .Days }}
{{ with index $campsite.Bookings $day -}}
<td class="booking-{{ .Status }}">
<div class="booking-status"
style="--booking-nights: {{ .Nights }}; --booking-begin: {{ if .Begin }}1{{ else }}0{{ end }}; --booking-end: {{ if .End }}1{{ else }}0{{ end }}"
title="{{ .Holder }}"
>{{ .Holder }}</div>
</td>
{{- else -}}
<td></td>
{{- end }}
{{- end }}
{{- end }}
</tr>
{{- end }}
</tbody>
</table>
</div>
<form id="booking-filter"> <form id="booking-filter">
<fieldset> <fieldset>
{{ with .From -}} {{ with .From -}}
@ -96,3 +43,11 @@
<p>{{( gettext "No campsites added yet." )}}</p> <p>{{( gettext "No campsites added yet." )}}</p>
{{- end }} {{- end }}
{{- end }} {{- end }}
{{ define "campsite-heading" -}}
{{- if isAdmin -}}
<a href="/admin/campsites/{{ .Label }}">{{ .Label }}</a>
{{- else -}}
{{ .Label }}
{{- end -}}
{{- end }}