feat: set timezone user level
This commit is contained in:
parent
ad0989510a
commit
945069052f
11 changed files with 261 additions and 18 deletions
|
|
@ -87,8 +87,8 @@ func New(cfg *config.Config) (*App, error) {
|
|||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
|
||||
moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository)
|
||||
paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository)
|
||||
recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository)
|
||||
recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService)
|
||||
recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository, profileRepository)
|
||||
recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService, profileRepository)
|
||||
budgetService := service.NewBudgetService(budgetRepository)
|
||||
reportService := service.NewReportService(expenseRepository)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE profiles ADD COLUMN timezone TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE profiles DROP COLUMN IF EXISTS timezone;
|
||||
|
|
@ -15,15 +15,25 @@ import (
|
|||
type settingsHandler struct {
|
||||
authService *service.AuthService
|
||||
userService *service.UserService
|
||||
profileService *service.ProfileService
|
||||
}
|
||||
|
||||
func NewSettingsHandler(authService *service.AuthService, userService *service.UserService) *settingsHandler {
|
||||
func NewSettingsHandler(authService *service.AuthService, userService *service.UserService, profileService *service.ProfileService) *settingsHandler {
|
||||
return &settingsHandler{
|
||||
authService: authService,
|
||||
userService: userService,
|
||||
profileService: profileService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *settingsHandler) currentTimezone(r *http.Request) string {
|
||||
profile := ctxkeys.Profile(r.Context())
|
||||
if profile != nil && profile.Timezone != nil {
|
||||
return *profile.Timezone
|
||||
}
|
||||
return "UTC"
|
||||
}
|
||||
|
||||
func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
user := ctxkeys.User(r.Context())
|
||||
|
||||
|
|
@ -35,7 +45,7 @@ func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), ""))
|
||||
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "", h.currentTimezone(r)))
|
||||
}
|
||||
|
||||
func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -53,6 +63,8 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
currentTz := h.currentTimezone(r)
|
||||
|
||||
err = h.authService.SetPassword(user.ID, currentPassword, newPassword, confirmPassword)
|
||||
if err != nil {
|
||||
slog.Warn("set password failed", "error", err, "user_id", user.ID)
|
||||
|
|
@ -66,12 +78,12 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
|
|||
msg = "Password must be at least 12 characters"
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg))
|
||||
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg, currentTz))
|
||||
return
|
||||
}
|
||||
|
||||
// Password set successfully — render page with success toast
|
||||
ui.Render(w, r, pages.AppSettings(true, ""))
|
||||
ui.Render(w, r, pages.AppSettings(true, "", currentTz))
|
||||
ui.RenderToast(w, r, toast.Toast(toast.Props{
|
||||
Title: "Password updated",
|
||||
Variant: toast.VariantSuccess,
|
||||
|
|
@ -80,3 +92,37 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
|
|||
Duration: 5000,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *settingsHandler) SetTimezone(w http.ResponseWriter, r *http.Request) {
|
||||
user := ctxkeys.User(r.Context())
|
||||
tz := r.FormValue("timezone")
|
||||
|
||||
fullUser, err := h.userService.ByID(user.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to fetch user for set timezone", "error", err, "user_id", user.ID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.profileService.UpdateTimezone(user.ID, tz)
|
||||
if err != nil {
|
||||
slog.Warn("set timezone failed", "error", err, "user_id", user.ID)
|
||||
|
||||
msg := "Invalid timezone selected"
|
||||
if !errors.Is(err, service.ErrInvalidTimezone) {
|
||||
msg = "An error occurred. Please try again."
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg, h.currentTimezone(r)))
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "", tz))
|
||||
ui.RenderToast(w, r, toast.Toast(toast.Props{
|
||||
Title: "Timezone updated",
|
||||
Variant: toast.VariantSuccess,
|
||||
Icon: true,
|
||||
Dismissible: true,
|
||||
Duration: 5000,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@ type Profile struct {
|
|||
ID string `db:"id"`
|
||||
UserID string `db:"user_id"`
|
||||
Name string `db:"name"`
|
||||
Timezone *string `db:"timezone"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
// Location returns the *time.Location for this profile's timezone.
|
||||
// Returns UTC if timezone is nil or invalid.
|
||||
func (p *Profile) Location() *time.Location {
|
||||
if p.Timezone == nil || *p.Timezone == "" {
|
||||
return time.UTC
|
||||
}
|
||||
loc, err := time.LoadLocation(*p.Timezone)
|
||||
if err != nil {
|
||||
return time.UTC
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type ProfileRepository interface {
|
|||
Create(profile *model.Profile) (string, error)
|
||||
ByUserID(userID string) (*model.Profile, error)
|
||||
UpdateName(userID, name string) error
|
||||
UpdateTimezone(userID, timezone string) error
|
||||
}
|
||||
|
||||
type profileRepository struct {
|
||||
|
|
@ -61,6 +62,28 @@ func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) {
|
|||
return &profile, nil
|
||||
}
|
||||
|
||||
func (r *profileRepository) UpdateTimezone(userID, timezone string) error {
|
||||
result, err := r.db.Exec(`
|
||||
UPDATE profiles
|
||||
SET timezone = $1, updated_at = $2
|
||||
WHERE user_id = $3
|
||||
`, timezone, time.Now(), userID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("no profile found for user_id: %s", userID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *profileRepository) UpdateName(userID, name string) error {
|
||||
result, err := r.db.Exec(`
|
||||
UPDATE profiles
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
func SetupRoutes(a *app.App) http.Handler {
|
||||
auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
|
||||
home := handler.NewHomeHandler()
|
||||
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
||||
settings := handler.NewSettingsHandler(a.AuthService, a.UserService, a.ProfileService)
|
||||
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.RecurringDepositService, a.BudgetService, a.ReportService)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
|
@ -59,6 +59,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
mux.Handle("POST /app/spaces", crudLimiter(middleware.RequireAuth(space.CreateSpace)))
|
||||
mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
|
||||
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
|
||||
mux.HandleFunc("POST /app/settings/timezone", middleware.RequireAuth(settings.SetTimezone))
|
||||
|
||||
// Space routes — wrapping order: Auth(SpaceAccess(handler))
|
||||
// Auth runs first (outer), then SpaceAccess (inner), then the handler.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
)
|
||||
|
||||
var ErrInvalidTimezone = errors.New("invalid timezone")
|
||||
|
||||
type ProfileService struct {
|
||||
profileRepository repository.ProfileRepository
|
||||
}
|
||||
|
|
@ -18,3 +23,10 @@ func NewProfileService(profileRepository repository.ProfileRepository) *ProfileS
|
|||
func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) {
|
||||
return s.profileRepository.ByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *ProfileService) UpdateTimezone(userID, timezone string) error {
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
return ErrInvalidTimezone
|
||||
}
|
||||
return s.profileRepository.UpdateTimezone(userID, timezone)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,13 +36,15 @@ type RecurringDepositService struct {
|
|||
recurringRepo repository.RecurringDepositRepository
|
||||
accountRepo repository.MoneyAccountRepository
|
||||
expenseService *ExpenseService
|
||||
profileRepo repository.ProfileRepository
|
||||
}
|
||||
|
||||
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService) *RecurringDepositService {
|
||||
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository) *RecurringDepositService {
|
||||
return &RecurringDepositService{
|
||||
recurringRepo: recurringRepo,
|
||||
accountRepo: accountRepo,
|
||||
expenseService: expenseService,
|
||||
profileRepo: profileRepo,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,8 +163,10 @@ func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error {
|
|||
return fmt.Errorf("failed to get due recurring deposits: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, rd := range dues {
|
||||
if err := s.processRecurrence(rd, now); err != nil {
|
||||
userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(rd, userNow); err != nil {
|
||||
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -175,14 +179,30 @@ func (s *RecurringDepositService) ProcessDueRecurrencesForSpace(spaceID string,
|
|||
return fmt.Errorf("failed to get due recurring deposits for space: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, rd := range dues {
|
||||
if err := s.processRecurrence(rd, now); err != nil {
|
||||
userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(rd, userNow); err != nil {
|
||||
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) getUserNow(userID string, now time.Time, cache map[string]*time.Location) time.Time {
|
||||
if loc, ok := cache[userID]; ok {
|
||||
return now.In(loc)
|
||||
}
|
||||
|
||||
loc := time.UTC
|
||||
profile, err := s.profileRepo.ByUserID(userID)
|
||||
if err == nil && profile != nil {
|
||||
loc = profile.Location()
|
||||
}
|
||||
cache[userID] = loc
|
||||
return now.In(loc)
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) {
|
||||
totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -38,12 +38,14 @@ type UpdateRecurringExpenseDTO struct {
|
|||
type RecurringExpenseService struct {
|
||||
recurringRepo repository.RecurringExpenseRepository
|
||||
expenseRepo repository.ExpenseRepository
|
||||
profileRepo repository.ProfileRepository
|
||||
}
|
||||
|
||||
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository) *RecurringExpenseService {
|
||||
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository, profileRepo repository.ProfileRepository) *RecurringExpenseService {
|
||||
return &RecurringExpenseService{
|
||||
recurringRepo: recurringRepo,
|
||||
expenseRepo: expenseRepo,
|
||||
profileRepo: profileRepo,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,8 +178,10 @@ func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
|
|||
return fmt.Errorf("failed to get due recurrences: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, re := range dues {
|
||||
if err := s.processRecurrence(re, now); err != nil {
|
||||
userNow := s.getUserNow(re.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(re, userNow); err != nil {
|
||||
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -190,8 +194,10 @@ func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string,
|
|||
return fmt.Errorf("failed to get due recurrences for space: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, re := range dues {
|
||||
if err := s.processRecurrence(re, now); err != nil {
|
||||
userNow := s.getUserNow(re.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(re, userNow); err != nil {
|
||||
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -247,6 +253,22 @@ func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense,
|
|||
return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence)
|
||||
}
|
||||
|
||||
// getUserNow converts the server time to the user's local time based on their
|
||||
// profile timezone setting. Falls back to UTC if no timezone is set.
|
||||
func (s *RecurringExpenseService) getUserNow(userID string, now time.Time, cache map[string]*time.Location) time.Time {
|
||||
if loc, ok := cache[userID]; ok {
|
||||
return now.In(loc)
|
||||
}
|
||||
|
||||
loc := time.UTC
|
||||
profile, err := s.profileRepo.ByUserID(userID)
|
||||
if err == nil && profile != nil {
|
||||
loc = profile.Location()
|
||||
}
|
||||
cache[userID] = loc
|
||||
return now.In(loc)
|
||||
}
|
||||
|
||||
func AdvanceDate(date time.Time, freq model.Frequency) time.Time {
|
||||
switch freq {
|
||||
case model.FrequencyDaily:
|
||||
|
|
|
|||
59
internal/timezone/timezones.go
Normal file
59
internal/timezone/timezones.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package timezone
|
||||
|
||||
type TimezoneOption struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
func CommonTimezones() []TimezoneOption {
|
||||
return []TimezoneOption{
|
||||
{Value: "UTC", Label: "UTC"},
|
||||
{Value: "America/New_York", Label: "Eastern Time (US & Canada)"},
|
||||
{Value: "America/Chicago", Label: "Central Time (US & Canada)"},
|
||||
{Value: "America/Denver", Label: "Mountain Time (US & Canada)"},
|
||||
{Value: "America/Los_Angeles", Label: "Pacific Time (US & Canada)"},
|
||||
{Value: "America/Anchorage", Label: "Alaska"},
|
||||
{Value: "Pacific/Honolulu", Label: "Hawaii"},
|
||||
{Value: "America/Phoenix", Label: "Arizona"},
|
||||
{Value: "America/Toronto", Label: "Toronto"},
|
||||
{Value: "America/Vancouver", Label: "Vancouver"},
|
||||
{Value: "America/Edmonton", Label: "Edmonton"},
|
||||
{Value: "America/Winnipeg", Label: "Winnipeg"},
|
||||
{Value: "America/Halifax", Label: "Atlantic Time (Canada)"},
|
||||
{Value: "America/St_Johns", Label: "Newfoundland"},
|
||||
{Value: "America/Mexico_City", Label: "Mexico City"},
|
||||
{Value: "America/Bogota", Label: "Bogota"},
|
||||
{Value: "America/Lima", Label: "Lima"},
|
||||
{Value: "America/Santiago", Label: "Santiago"},
|
||||
{Value: "America/Argentina/Buenos_Aires", Label: "Buenos Aires"},
|
||||
{Value: "America/Sao_Paulo", Label: "Sao Paulo"},
|
||||
{Value: "Europe/London", Label: "London"},
|
||||
{Value: "Europe/Paris", Label: "Paris"},
|
||||
{Value: "Europe/Berlin", Label: "Berlin"},
|
||||
{Value: "Europe/Madrid", Label: "Madrid"},
|
||||
{Value: "Europe/Rome", Label: "Rome"},
|
||||
{Value: "Europe/Amsterdam", Label: "Amsterdam"},
|
||||
{Value: "Europe/Zurich", Label: "Zurich"},
|
||||
{Value: "Europe/Stockholm", Label: "Stockholm"},
|
||||
{Value: "Europe/Helsinki", Label: "Helsinki"},
|
||||
{Value: "Europe/Warsaw", Label: "Warsaw"},
|
||||
{Value: "Europe/Istanbul", Label: "Istanbul"},
|
||||
{Value: "Europe/Moscow", Label: "Moscow"},
|
||||
{Value: "Asia/Dubai", Label: "Dubai"},
|
||||
{Value: "Asia/Kolkata", Label: "India (Kolkata)"},
|
||||
{Value: "Asia/Bangkok", Label: "Bangkok"},
|
||||
{Value: "Asia/Singapore", Label: "Singapore"},
|
||||
{Value: "Asia/Hong_Kong", Label: "Hong Kong"},
|
||||
{Value: "Asia/Shanghai", Label: "Shanghai"},
|
||||
{Value: "Asia/Tokyo", Label: "Tokyo"},
|
||||
{Value: "Asia/Seoul", Label: "Seoul"},
|
||||
{Value: "Australia/Sydney", Label: "Sydney"},
|
||||
{Value: "Australia/Melbourne", Label: "Melbourne"},
|
||||
{Value: "Australia/Perth", Label: "Perth"},
|
||||
{Value: "Australia/Brisbane", Label: "Brisbane"},
|
||||
{Value: "Pacific/Auckland", Label: "Auckland"},
|
||||
{Value: "Africa/Cairo", Label: "Cairo"},
|
||||
{Value: "Africa/Johannesburg", Label: "Johannesburg"},
|
||||
{Value: "Africa/Lagos", Label: "Lagos"},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/timezone"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
|
|
@ -8,15 +9,55 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
)
|
||||
|
||||
templ AppSettings(hasPassword bool, errorMsg string) {
|
||||
templ AppSettings(hasPassword bool, errorMsg string, currentTimezone string) {
|
||||
@layouts.App("Settings") {
|
||||
<div class="container max-w-2xl px-6 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold">Settings</h1>
|
||||
<p class="text-muted-foreground mt-2">Manage your account settings</p>
|
||||
</div>
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
Timezone
|
||||
}
|
||||
@card.Description() {
|
||||
Set your timezone for recurring expenses and reports
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
<form action="/app/settings/timezone" method="POST" class="space-y-4">
|
||||
@csrf.Token()
|
||||
@form.Item() {
|
||||
@label.Label(label.Props{
|
||||
For: "timezone",
|
||||
Class: "block mb-2",
|
||||
}) {
|
||||
Timezone
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "timezone-select"}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
|
||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
|
||||
for _, tz := range timezone.CommonTimezones() {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: tz.Value == currentTimezone}) {
|
||||
{ tz.Label }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@button.Submit() {
|
||||
Update Timezone
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
<div class="mt-6"></div>
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue