mirror of
https://github.com/rjNemo/rentease.git
synced 2026-06-06 02:36:49 +00:00
feat(payments): add Stripe dashboard links for card payments
Some checks are pending
CI / checks (push) Waiting to run
Some checks are pending
CI / checks (push) Waiting to run
- Add `APP_STRIPE_ACCOUNT_ID` to config and README. - Pass Stripe account ID to payment view models. - Show "View in Stripe" badge linking to the payment in Stripe dashboard for card payments when account ID and payment ID are present. - Update Makefile to run format/lint locally instead of in container. - Update templates and generated code to support new dashboard link.
This commit is contained in:
parent
4dc4d2f2b5
commit
a0b7672e9e
9 changed files with 100 additions and 71 deletions
15
Makefile
15
Makefile
|
|
@ -25,19 +25,18 @@ dev: ## Build and run the dev container with live reload (Air)
|
|||
$(DOCKER_RUN_ENV) $(NAME):dev
|
||||
|
||||
test: ## Run Go tests inside the running dev container
|
||||
docker exec $(NAME) go test ./...
|
||||
go test ./...
|
||||
|
||||
up-deps: ## Update Go dependencies on host
|
||||
go get -u ./...
|
||||
|
||||
format: ## Generate templ files and format code (dev container must be running)
|
||||
docker exec $(NAME) templ generate internal/view
|
||||
docker exec $(NAME) templ fmt .
|
||||
docker exec $(NAME) go fmt ./...
|
||||
format: ## Generate templ files and format code locally
|
||||
templ generate internal/view
|
||||
templ fmt .
|
||||
go fmt ./...
|
||||
|
||||
lint: ## Lint the code using golangci-lint (dev container must be running)
|
||||
docker exec $(NAME) golangci-lint run ./...
|
||||
lint: ## Lint the code using golangci-lint locally
|
||||
golangci-lint run ./...
|
||||
|
||||
stop: ## Stop the dev container
|
||||
-@docker stop $(NAME) >/dev/null 2>&1 || true
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,10 @@ use a cloud alternative such as Railway, fly.io, _etc._
|
|||
# Stripe configuration (optional until you enable automatic sync)
|
||||
APP_STRIPE_SECRET_KEY=sk_test_your_key
|
||||
APP_STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||
APP_STRIPE_ACCOUNT_ID=acct_your_account_id
|
||||
```
|
||||
|
||||
Leave the Stripe variables blank to continue using manual cash entry only. When set, Rentease will pull payments from Stripe, process webhooks sent to `/webhooks/stripe`, and expose a manual sync endpoint at `POST /api/stripe/sync` (protected by the existing API key middleware).
|
||||
Leave the Stripe variables blank to continue using manual cash entry only. When set, Rentease will pull payments from Stripe, process webhooks sent to `/webhooks/stripe`, and expose a manual sync endpoint at `POST /api/stripe/sync` (protected by the existing API key middleware). Providing `APP_STRIPE_ACCOUNT_ID` also enables dashboard links for synchronized card payments.
|
||||
|
||||
5. Start the application
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,18 @@ package config
|
|||
import "os"
|
||||
|
||||
type Host struct {
|
||||
Items map[string]HostItem
|
||||
Name string
|
||||
Address string
|
||||
City string
|
||||
ZipCode string
|
||||
PhoneNumber string
|
||||
Email string
|
||||
InvoicePrefix string
|
||||
PaymentMethods []PaymentMethod
|
||||
Platforms []Platform
|
||||
CustomerSeed int
|
||||
Items map[string]HostItem
|
||||
Name string
|
||||
Address string
|
||||
City string
|
||||
ZipCode string
|
||||
PhoneNumber string
|
||||
Email string
|
||||
InvoicePrefix string
|
||||
PaymentMethods []PaymentMethod
|
||||
Platforms []Platform
|
||||
CustomerSeed int
|
||||
StripeAccountID string
|
||||
}
|
||||
|
||||
type HostItem struct {
|
||||
|
|
@ -30,16 +31,17 @@ type HostItem struct {
|
|||
|
||||
func NewHost() *Host {
|
||||
return &Host{
|
||||
Name: "VillaFleurie",
|
||||
Address: "4 rue Gerty Archimede",
|
||||
City: "Le Gosier",
|
||||
ZipCode: "97190",
|
||||
PhoneNumber: "+590 690 44 15 30",
|
||||
Email: "location.villafleurie@gmail.com",
|
||||
CustomerSeed: 239,
|
||||
InvoicePrefix: "VFNI",
|
||||
PaymentMethods: []PaymentMethod{"Card", "Cash", "Cheque", "Transfer"}, // TODO: add to DB
|
||||
Platforms: []Platform{"Booking", "AirBnb", "TripAdvisor", "Other"}, // TODO: add to DB
|
||||
Name: "VillaFleurie",
|
||||
Address: "4 rue Gerty Archimede",
|
||||
City: "Le Gosier",
|
||||
ZipCode: "97190",
|
||||
PhoneNumber: "+590 690 44 15 30",
|
||||
Email: "location.villafleurie@gmail.com",
|
||||
CustomerSeed: 239,
|
||||
InvoicePrefix: "VFNI",
|
||||
PaymentMethods: []PaymentMethod{"Card", "Cash", "Cheque", "Transfer"}, // TODO: add to DB
|
||||
Platforms: []Platform{"Booking", "AirBnb", "TripAdvisor", "Other"}, // TODO: add to DB
|
||||
StripeAccountID: os.Getenv("APP_STRIPE_ACCOUNT_ID"),
|
||||
Items: map[string]HostItem{ // TODO: move to DB
|
||||
"T2": {
|
||||
Name: "T2",
|
||||
|
|
|
|||
|
|
@ -63,17 +63,23 @@ func handleBookingListPage(bs *booking.Service, hc *config.Host) http.HandlerFun
|
|||
}
|
||||
}
|
||||
|
||||
func paymentViewModelFromBookingPayment(p booking.Payment) *view.PaymentViewModel {
|
||||
func paymentViewModelFromBookingPayment(p booking.Payment, stripeAccountID string) *view.PaymentViewModel {
|
||||
stripeStatus := ""
|
||||
if p.StripeStatus != nil {
|
||||
stripeStatus = *p.StripeStatus
|
||||
}
|
||||
|
||||
var stripeDashboardURL string
|
||||
if string(p.PaymentMethod) == "Card" && stripeAccountID != "" && p.StripePaymentID != nil && *p.StripePaymentID != "" {
|
||||
stripeDashboardURL = fmt.Sprintf("https://dashboard.stripe.com/%s/payments/%s", stripeAccountID, *p.StripePaymentID)
|
||||
}
|
||||
|
||||
return &view.PaymentViewModel{
|
||||
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
|
||||
PaymentMethod: string(p.PaymentMethod),
|
||||
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
|
||||
StripeStatus: stripeStatus,
|
||||
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
|
||||
PaymentMethod: string(p.PaymentMethod),
|
||||
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
|
||||
StripeStatus: stripeStatus,
|
||||
StripeDashboardURL: stripeDashboardURL,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +205,7 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) http.HandlerFunc {
|
|||
}
|
||||
}),
|
||||
Payments: u.Map(b.Payments, func(p booking.Payment) view.PaymentViewModel {
|
||||
return *paymentViewModelFromBookingPayment(p)
|
||||
return *paymentViewModelFromBookingPayment(p, hc.StripeAccountID)
|
||||
}),
|
||||
},
|
||||
Total: strconv.FormatFloat(u.Reduce(b.Items, func(i booking.Item, sum float64) float64 {
|
||||
|
|
@ -493,7 +499,7 @@ func handleItemUpdate(bs *booking.Service) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func handlePaymentUpdate(bs *booking.Service) http.HandlerFunc {
|
||||
func handlePaymentUpdate(bs *booking.Service, hc *config.Host) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
|
@ -517,7 +523,7 @@ func handlePaymentUpdate(bs *booking.Service) http.HandlerFunc {
|
|||
|
||||
p := bs.UpdatePayment(id, amount, r.FormValue("paymentMethod"))
|
||||
|
||||
if err := renderTempl(w, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p))); err != nil {
|
||||
if err := renderTempl(w, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import (
|
|||
"github.com/go-chi/chi/v5"
|
||||
u "github.com/rjNemo/underscore"
|
||||
|
||||
"github.com/rjNemo/rentease/internal/config"
|
||||
"github.com/rjNemo/rentease/internal/service/booking"
|
||||
"github.com/rjNemo/rentease/internal/view"
|
||||
)
|
||||
|
||||
func handleCreatePayment(bs *booking.Service) http.HandlerFunc {
|
||||
func handleCreatePayment(bs *booking.Service, hc *config.Host) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
|
@ -44,7 +45,7 @@ func handleCreatePayment(bs *booking.Service) http.HandlerFunc {
|
|||
|
||||
component := view.PaymentList(
|
||||
u.Map(nb.Payments, func(p booking.Payment) *view.PaymentViewModel {
|
||||
return paymentViewModelFromBookingPayment(p)
|
||||
return paymentViewModelFromBookingPayment(p, hc.StripeAccountID)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ func handleCreatePayment(bs *booking.Service) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func handlePaymentForm(bs *booking.Service) http.HandlerFunc {
|
||||
func handlePaymentForm(bs *booking.Service, hc *config.Host) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
|
|
@ -63,7 +64,7 @@ func handlePaymentForm(bs *booking.Service) http.HandlerFunc {
|
|||
}
|
||||
|
||||
p := bs.OnePayment(id)
|
||||
form := view.PaymentForm(paymentViewModelFromBookingPayment(*p))
|
||||
form := view.PaymentForm(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))
|
||||
if err := renderTempl(w, http.StatusOK, form); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ func (s *Server) MountHandlers() {
|
|||
r.Get("/reports/do", handleReportCompute(s.bs, s.hc))
|
||||
r.Get("/reports/pdf", handlePdfCreateReport(s.bs))
|
||||
|
||||
r.Post("/payments/{id}", handleCreatePayment(s.bs))
|
||||
r.Put("/payments/{id}", handlePaymentUpdate(s.bs))
|
||||
r.Get("/payments/{id}", handlePaymentForm(s.bs))
|
||||
r.Post("/payments/{id}", handleCreatePayment(s.bs, s.hc))
|
||||
r.Put("/payments/{id}", handlePaymentUpdate(s.bs, s.hc))
|
||||
r.Get("/payments/{id}", handlePaymentForm(s.bs, s.hc))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ type BookingViewModel struct {
|
|||
}
|
||||
|
||||
type PaymentViewModel struct {
|
||||
Amount string
|
||||
PaymentMethod string
|
||||
PaymentUrl string
|
||||
StripeStatus string
|
||||
Amount string
|
||||
PaymentMethod string
|
||||
PaymentUrl string
|
||||
StripeStatus string
|
||||
StripeDashboardURL string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,16 @@ templ PaymentLine(payment *PaymentViewModel) {
|
|||
<td>- { payment.Amount }</td>
|
||||
<td>
|
||||
{ payment.PaymentMethod }
|
||||
<span class="badge badge-outline badge-sm ml-2">{ payment.StripeStatus }</span>
|
||||
if payment.StripeDashboardURL != "" {
|
||||
<a
|
||||
href={ payment.StripeDashboardURL }
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="badge badge-outline badge-sm ml-2"
|
||||
>
|
||||
View in Stripe
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -55,33 +55,43 @@ func PaymentLine(payment *PaymentViewModel) templ.Component {
|
|||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " <span class=\"badge badge-outline badge-sm ml-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(payment.StripeStatus)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 10, Col: 73}
|
||||
if payment.StripeDashboardURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 templ.SafeURL
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(payment.StripeDashboardURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 12, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"badge badge-outline badge-sm ml-2\">View in Stripe</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></td><td></td><td class=\"flex gap-2\"><button class=\"btn btn-sm btn-outline\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</td><td></td><td class=\"flex gap-2\"><button class=\"btn btn-sm btn-outline\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 16, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 25, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">Edit</button></td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">Edit</button></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
|
@ -110,46 +120,46 @@ func PaymentForm(payment *PaymentViewModel) templ.Component {
|
|||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<tr class=\"hover\"><form hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr class=\"hover\"><form hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 28, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 37, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" id=\"edit-payment\" hx-target=\"closest tr\" hx-swap=\"outerHTML\"><td></td><td></td><td><input class=\"input input-bordered input-sm w-full\" type=\"number\" inputmode=\"decimal\" step=\"0.01\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" id=\"edit-payment\" hx-target=\"closest tr\" hx-swap=\"outerHTML\"><td></td><td></td><td><input class=\"input input-bordered input-sm w-full\" type=\"number\" inputmode=\"decimal\" step=\"0.01\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(payment.Amount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 37, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 46, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" name=\"amount\" form=\"edit-payment\"></td><td><input class=\"input input-bordered input-sm w-full\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" name=\"amount\" form=\"edit-payment\"></td><td><input class=\"input input-bordered input-sm w-full\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentMethod)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 45, Col: 34}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 54, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" name=\"paymentMethod\" form=\"edit-payment\"></td><td></td><td><button class=\"btn btn-sm btn-primary\" type=\"submit\" form=\"edit-payment\">Save</button></td></form></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" name=\"paymentMethod\" form=\"edit-payment\"></td><td></td><td><button class=\"btn btn-sm btn-primary\" type=\"submit\" form=\"edit-payment\">Save</button></td></form></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
|
@ -178,7 +188,7 @@ func PaymentList(payments []*PaymentViewModel) templ.Component {
|
|||
templ_7745c5c3_Var10 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<tbody id=\"payment-lines\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tbody id=\"payment-lines\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
|
@ -190,7 +200,7 @@ func PaymentList(payments []*PaymentViewModel) templ.Component {
|
|||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue