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:
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/wotra/wotra/internal/service"
|
"github.com/wotra/wotra/internal/service"
|
||||||
@@ -21,6 +23,8 @@ func (h *SettingsHandler) Routes(r chi.Router) {
|
|||||||
r.Get("/settings", h.Current)
|
r.Get("/settings", h.Current)
|
||||||
r.Put("/settings", h.Upsert)
|
r.Put("/settings", h.Upsert)
|
||||||
r.Get("/settings/history", h.History)
|
r.Get("/settings/history", h.History)
|
||||||
|
r.Put("/settings/history/{id}", h.UpdateHistoryRow)
|
||||||
|
r.Delete("/settings/history/{id}", h.DeleteHistoryRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current GET /api/settings
|
// Current GET /api/settings
|
||||||
@@ -76,3 +80,62 @@ func (h *SettingsHandler) History(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, history)
|
writeJSON(w, http.StatusOK, history)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateHistoryRow PUT /api/settings/history/{id}
|
||||||
|
func (h *SettingsHandler) UpdateHistoryRow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
EffectiveFrom string `json:"effective_from"`
|
||||||
|
HoursPerWeek float64 `json:"hours_per_week"`
|
||||||
|
WorkdaysMask int `json:"workdays_mask"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set, err := h.svc.UpdateSettings(r.Context(), id, service.UpdateSettingsInput{
|
||||||
|
EffectiveFrom: body.EffectiveFrom,
|
||||||
|
HoursPerWeek: body.HoursPerWeek,
|
||||||
|
WorkdaysMask: body.WorkdaysMask,
|
||||||
|
Timezone: body.Timezone,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrSettingsNotFound), errors.Is(err, sql.ErrNoRows):
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, service.ErrInvalidHours), errors.Is(err, service.ErrInvalidWorkdaysMask):
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, set)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteHistoryRow DELETE /api/settings/history/{id}
|
||||||
|
func (h *SettingsHandler) DeleteHistoryRow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteSettings(r.Context(), id); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrSettingsNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, service.ErrLastSettingsRow):
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +15,8 @@ var (
|
|||||||
ErrNoSettings = errors.New("no settings found")
|
ErrNoSettings = errors.New("no settings found")
|
||||||
ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127")
|
ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127")
|
||||||
ErrInvalidHours = errors.New("hours_per_week must be > 0")
|
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.
|
// SettingsService manages settings with effective-from history.
|
||||||
@@ -87,3 +90,67 @@ func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput)
|
|||||||
}
|
}
|
||||||
return set, nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,37 @@ func (s *SettingsStore) Insert(ctx context.Context, set *domain.Settings) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update overwrites an existing settings row by ID.
|
||||||
|
func (s *SettingsStore) Update(ctx context.Context, set *domain.Settings) error {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE settings_history
|
||||||
|
SET effective_from=?, hours_per_week=?, workdays_mask=?, timezone=?
|
||||||
|
WHERE id=?`,
|
||||||
|
set.EffectiveFrom, set.HoursPerWeek, set.WorkdaysMask, set.Timezone, set.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a settings row by ID.
|
||||||
|
func (s *SettingsStore) Delete(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `DELETE FROM settings_history WHERE id=?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total number of settings rows.
|
||||||
|
func (s *SettingsStore) Count(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM settings_history`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a single settings row by ID.
|
||||||
|
func (s *SettingsStore) GetByID(ctx context.Context, id int64) (*domain.Settings, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||||
|
FROM settings_history WHERE id=?`, id)
|
||||||
|
return scanSettings(row)
|
||||||
|
}
|
||||||
|
|
||||||
func scanSettings(row *sql.Row) (*domain.Settings, error) {
|
func scanSettings(row *sql.Row) (*domain.Settings, error) {
|
||||||
var s domain.Settings
|
var s domain.Settings
|
||||||
err := row.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt)
|
err := row.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt)
|
||||||
|
|||||||
@@ -137,7 +137,14 @@ export const settings = {
|
|||||||
hours_per_week: number;
|
hours_per_week: number;
|
||||||
workdays_mask: number;
|
workdays_mask: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}) => request<Settings>('PUT', '/settings', body)
|
}) => request<Settings>('PUT', '/settings', body),
|
||||||
|
update: (id: number, body: {
|
||||||
|
effective_from: string;
|
||||||
|
hours_per_week: number;
|
||||||
|
workdays_mask: number;
|
||||||
|
timezone: string;
|
||||||
|
}) => request<Settings>('PUT', `/settings/history/${id}`, body),
|
||||||
|
delete: (id: number) => request<void>('DELETE', `/settings/history/${id}`)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Health ──────────────────────────────────────────────────────────────────
|
// ─── Health ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
let formWorkdaysMask = $state(31);
|
let formWorkdaysMask = $state(31);
|
||||||
let formTimezone = $state('UTC');
|
let formTimezone = $state('UTC');
|
||||||
|
|
||||||
|
// Inline edit state for history rows
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let editEffectiveFrom = $state('');
|
||||||
|
let editHoursPerWeek = $state(0);
|
||||||
|
let editWorkdaysMask = $state(31);
|
||||||
|
let editTimezone = $state('');
|
||||||
|
let editError = $state('');
|
||||||
|
|
||||||
// Workday checkboxes: Mon=bit1..Sun=bit64
|
// Workday checkboxes: Mon=bit1..Sun=bit64
|
||||||
const DAYS = [
|
const DAYS = [
|
||||||
{ label: 'Mon', bit: 1 },
|
{ label: 'Mon', bit: 1 },
|
||||||
@@ -33,6 +41,14 @@
|
|||||||
formWorkdaysMask ^= bit;
|
formWorkdaysMask ^= bit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEditDay(bit: number) {
|
||||||
|
editWorkdaysMask ^= bit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskLabel(mask: number): string {
|
||||||
|
return DAYS.filter((d) => mask & d.bit).map((d) => d.label).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!hasToken()) return;
|
if (!hasToken()) return;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -70,9 +86,50 @@
|
|||||||
workdays_mask: formWorkdaysMask,
|
workdays_mask: formWorkdaysMask,
|
||||||
timezone: formTimezone
|
timezone: formTimezone
|
||||||
});
|
});
|
||||||
current = s;
|
|
||||||
history = [s, ...history];
|
|
||||||
saved = 'Settings saved!';
|
saved = 'Settings saved!';
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline edit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startEdit(s: Settings) {
|
||||||
|
editingId = s.id;
|
||||||
|
editEffectiveFrom = s.effective_from;
|
||||||
|
editHoursPerWeek = s.hours_per_week;
|
||||||
|
editWorkdaysMask = s.workdays_mask;
|
||||||
|
editTimezone = s.timezone;
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(id: number) {
|
||||||
|
editError = '';
|
||||||
|
try {
|
||||||
|
await settings.update(id, {
|
||||||
|
effective_from: editEffectiveFrom,
|
||||||
|
hours_per_week: editHoursPerWeek,
|
||||||
|
workdays_mask: editWorkdaysMask,
|
||||||
|
timezone: editTimezone
|
||||||
|
});
|
||||||
|
editingId = null;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await settings.delete(id);
|
||||||
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
}
|
}
|
||||||
@@ -103,7 +160,7 @@
|
|||||||
|
|
||||||
<!-- Settings form -->
|
<!-- Settings form -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Working Hours</h2>
|
<h2>Add / change working hours</h2>
|
||||||
<form onsubmit={handleSaveSettings}>
|
<form onsubmit={handleSaveSettings}>
|
||||||
<label>
|
<label>
|
||||||
Effective from
|
Effective from
|
||||||
@@ -138,6 +195,7 @@
|
|||||||
<h2>Current effective settings</h2>
|
<h2>Current effective settings</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Hours/week</dt><dd>{current.hours_per_week}h</dd>
|
<dt>Hours/week</dt><dd>{current.hours_per_week}h</dd>
|
||||||
|
<dt>Workdays</dt><dd>{maskLabel(current.workdays_mask)}</dd>
|
||||||
<dt>Timezone</dt><dd>{current.timezone}</dd>
|
<dt>Timezone</dt><dd>{current.timezone}</dd>
|
||||||
<dt>Effective from</dt><dd>{current.effective_from}</dd>
|
<dt>Effective from</dt><dd>{current.effective_from}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -149,13 +207,70 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Settings history</h2>
|
<h2>Settings history</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Effective from</th><th>Hours/week</th><th>Timezone</th></tr></thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Effective from</th>
|
||||||
|
<th>Hours/week</th>
|
||||||
|
<th>Workdays</th>
|
||||||
|
<th>Timezone</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each history as s (s.id)}
|
{#each history as s (s.id)}
|
||||||
<tr><td>{s.effective_from}</td><td>{s.hours_per_week}h</td><td>{s.timezone}</td></tr>
|
{#if editingId === s.id}
|
||||||
|
<tr class="editing-row">
|
||||||
|
<td colspan="5">
|
||||||
|
{#if editError}<p class="form-error">{editError}</p>{/if}
|
||||||
|
<div class="edit-form">
|
||||||
|
<label class="edit-field">
|
||||||
|
<span>Effective from</span>
|
||||||
|
<input type="date" bind:value={editEffectiveFrom} required />
|
||||||
|
</label>
|
||||||
|
<label class="edit-field">
|
||||||
|
<span>Hours/week</span>
|
||||||
|
<input type="number" min="1" max="168" step="0.5" bind:value={editHoursPerWeek} required />
|
||||||
|
</label>
|
||||||
|
<label class="edit-field">
|
||||||
|
<span>Timezone</span>
|
||||||
|
<input type="text" bind:value={editTimezone} />
|
||||||
|
</label>
|
||||||
|
<fieldset class="edit-days">
|
||||||
|
<legend>Workdays</legend>
|
||||||
|
<div class="days-row">
|
||||||
|
{#each DAYS as d}
|
||||||
|
<label class="day-check">
|
||||||
|
<input type="checkbox" checked={(editWorkdaysMask & d.bit) !== 0} onchange={() => toggleEditDay(d.bit)} />
|
||||||
|
{d.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="btn save" onclick={() => saveEdit(s.id)}>Save</button>
|
||||||
|
<button class="btn cancel" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td>{s.effective_from}</td>
|
||||||
|
<td>{s.hours_per_week}h</td>
|
||||||
|
<td>{maskLabel(s.workdays_mask)}</td>
|
||||||
|
<td>{s.timezone}</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<button class="action-btn edit" onclick={() => startEdit(s)} title="Edit">✎</button>
|
||||||
|
<button class="action-btn delete" onclick={() => handleDelete(s.id)} title="Delete" disabled={history.length <= 1}>✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{#if history.length <= 1}
|
||||||
|
<p class="hint">The last settings row cannot be deleted.</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -183,6 +298,24 @@
|
|||||||
dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; }
|
dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; }
|
||||||
dt { font-weight: 500; color: #6c757d; }
|
dt { font-weight: 500; color: #6c757d; }
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
th { background: #f8f9fa; padding: 0.4rem 0.75rem; text-align: left; }
|
th { background: #f8f9fa; padding: 0.4rem 0.75rem; text-align: left; font-weight: 600; color: #495057; }
|
||||||
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #f1f3f5; }
|
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #f1f3f5; vertical-align: middle; }
|
||||||
|
.row-actions { display: flex; gap: 0.3rem; white-space: nowrap; }
|
||||||
|
.action-btn { background: none; border: none; cursor: pointer; padding: 0.2rem 0.35rem;
|
||||||
|
border-radius: 4px; font-size: 0.95rem; color: #adb5bd; transition: color 0.15s; }
|
||||||
|
.action-btn:hover:not(:disabled) { color: #495057; }
|
||||||
|
.action-btn.edit:hover { color: #2c7be5; }
|
||||||
|
.action-btn.delete:hover:not(:disabled) { color: #e74c3c; }
|
||||||
|
.action-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.editing-row td { background: #f8f9fa; padding: 0.75rem; }
|
||||||
|
.edit-form { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
|
||||||
|
.edit-field { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; font-weight: 500; }
|
||||||
|
.edit-field input { padding: 0.35rem 0.6rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.9rem; }
|
||||||
|
.edit-days { border: 1px solid #dee2e6; border-radius: 6px; padding: 0.4rem 0.75rem; }
|
||||||
|
.edit-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.btn { padding: 0.4rem 1rem; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn.save { background: #2c7be5; color: #fff; }
|
||||||
|
.btn.cancel { background: #e9ecef; color: #343a40; }
|
||||||
|
.form-error { color: #c0392b; font-size: 0.85rem; margin: 0 0 0.5rem; }
|
||||||
|
.hint { font-size: 0.8rem; color: #6c757d; margin-top: 0.4rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user