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

@@ -137,7 +137,14 @@ export const settings = {
hours_per_week: number;
workdays_mask: number;
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 ──────────────────────────────────────────────────────────────────

View File

@@ -18,6 +18,14 @@
let formWorkdaysMask = $state(31);
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
const DAYS = [
{ label: 'Mon', bit: 1 },
@@ -33,6 +41,14 @@
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() {
if (!hasToken()) return;
error = '';
@@ -70,9 +86,50 @@
workdays_mask: formWorkdaysMask,
timezone: formTimezone
});
current = s;
history = [s, ...history];
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) {
error = e instanceof ApiError ? e.message : String(e);
}
@@ -103,7 +160,7 @@
<!-- Settings form -->
<section>
<h2>Working Hours</h2>
<h2>Add / change working hours</h2>
<form onsubmit={handleSaveSettings}>
<label>
Effective from
@@ -138,6 +195,7 @@
<h2>Current effective settings</h2>
<dl>
<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>Effective from</dt><dd>{current.effective_from}</dd>
</dl>
@@ -149,13 +207,70 @@
<section>
<h2>Settings history</h2>
<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>
{#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">&#9998;</button>
<button class="action-btn delete" onclick={() => handleDelete(s.id)} title="Delete" disabled={history.length <= 1}>&#10005;</button>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{#if history.length <= 1}
<p class="hint">The last settings row cannot be deleted.</p>
{/if}
</section>
{/if}
{/if}
@@ -183,6 +298,24 @@
dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; }
dt { font-weight: 500; color: #6c757d; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th { background: #f8f9fa; padding: 0.4rem 0.75rem; text-align: left; }
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #f1f3f5; }
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; 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>