feat: edit and delete settings history rows

Backend:
- SettingsStore: Add GetByID, Update, Delete, Count methods
- SettingsService: Add UpdateSettings (validates same rules as Upsert),
  DeleteSettings (guards against deleting the last row → 409)
- New sentinels: ErrSettingsNotFound, ErrLastSettingsRow
- Handler: PUT /api/settings/history/{id} → 200 updated row
           DELETE /api/settings/history/{id} → 204 / 404 / 409

Frontend:
- API client: settings.update(id, body) and settings.delete(id)
- Settings page: history table gains edit (pencil) and delete (×) buttons
- Inline edit form expands in place within the table row
- Delete button disabled and hint shown when only one row remains
- maskLabel() helper shows workday names instead of raw bitmask
- After save/delete: full reload to reflect changes in 'current' section
This commit is contained in:
2026-04-30 19:50:27 +02:00
parent b25340644b
commit 15bf3c3a18
5 changed files with 309 additions and 8 deletions

View File

@@ -2,6 +2,7 @@ package service
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -14,6 +15,8 @@ var (
ErrNoSettings = errors.New("no settings found")
ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127")
ErrInvalidHours = errors.New("hours_per_week must be > 0")
ErrSettingsNotFound = errors.New("settings row not found")
ErrLastSettingsRow = errors.New("cannot delete the only settings row")
)
// SettingsService manages settings with effective-from history.
@@ -87,3 +90,67 @@ func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput)
}
return set, nil
}
// UpdateSettingsInput is the payload for editing an existing settings row.
type UpdateSettingsInput struct {
EffectiveFrom string
HoursPerWeek float64
WorkdaysMask int
Timezone string
}
// UpdateSettings edits an existing settings row in-place.
func (s *SettingsService) UpdateSettings(ctx context.Context, id int64, input UpdateSettingsInput) (*domain.Settings, error) {
if input.HoursPerWeek <= 0 {
return nil, ErrInvalidHours
}
if input.WorkdaysMask < 1 || input.WorkdaysMask > 127 {
return nil, ErrInvalidWorkdaysMask
}
if input.Timezone == "" {
input.Timezone = "UTC"
}
if _, err := time.LoadLocation(input.Timezone); err != nil {
return nil, fmt.Errorf("invalid timezone: %w", err)
}
if _, err := time.Parse("2006-01-02", input.EffectiveFrom); err != nil {
return nil, fmt.Errorf("invalid effective_from: %w", err)
}
set, err := s.store.GetByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSettingsNotFound
}
return nil, err
}
set.EffectiveFrom = input.EffectiveFrom
set.HoursPerWeek = input.HoursPerWeek
set.WorkdaysMask = input.WorkdaysMask
set.Timezone = input.Timezone
if err := s.store.Update(ctx, set); err != nil {
return nil, err
}
return set, nil
}
// DeleteSettings removes a settings row. Refuses if it is the only row.
func (s *SettingsService) DeleteSettings(ctx context.Context, id int64) error {
count, err := s.store.Count(ctx)
if err != nil {
return err
}
if count <= 1 {
return ErrLastSettingsRow
}
// Confirm row exists before deleting.
if _, err := s.store.GetByID(ctx, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrSettingsNotFound
}
return err
}
return s.store.Delete(ctx, id)
}