diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index ec7dc24..008868f 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -5,7 +5,8 @@ let unitsData = $state({ units: [], groups: [] }); let selectedItem = $state<{ type: 'unit' | 'group'; id: string } | null>(null); let checkedUnitIds = $state>(new Set()); - let moveTargetGroupId = $state(''); + let bulkAction = $state(''); // '' | 'move' + let bulkMoveTargetGroupId = $state(''); let expandedGroups = $state>(new Set()); let deleteGroupTarget = $state(null); let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign'); @@ -22,7 +23,7 @@ let groupForm = $state>({}); let groupFormNew = $state(false); - // ── Load data on mount ─────────────────────────────────────────────────────── + // ── Load data ──────────────────────────────────────────────────────────────── async function loadData() { const [res, gres] = await Promise.all([ fetch('/admin/api/units'), @@ -32,16 +33,11 @@ const units: Unit[] = await res.json(); const groups: Group[] = gres.ok ? await gres.json() : []; unitsData = { units, groups }; - // Expand all groups — use assignment for consistent reactive update expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]); } - // ── Load on mount ──────────────────────────────────────────────────────────── - $effect(() => { - loadData(); - }); + $effect(() => { loadData(); }); - /** Extract a server error message from a non-ok response. */ async function extractError(res: Response, fallback: string): Promise { const body = await res.json().catch(() => ({})); return (body as Record).error ?? fallback; @@ -58,10 +54,6 @@ return map; }); - function getBaseUnit(group: Group): Unit | undefined { - return unitsData.units.find((u) => u.id === group.baseUnitId); - } - // ── Selection ──────────────────────────────────────────────────────────────── function selectUnit(unit: Unit) { selectedItem = { type: 'unit', id: unit.id }; @@ -99,29 +91,75 @@ formSuccess = null; } - // ── Unit form submit ───────────────────────────────────────────────────────── + // ── Checkbox helpers ───────────────────────────────────────────────────────── + function toggleCheck(unitId: string) { + const next = new Set(checkedUnitIds); + if (next.has(unitId)) next.delete(unitId); + else next.add(unitId); + checkedUnitIds = next; + } + + function toggleGroupCheck(groupId: string | null) { + const groupUnits = unitsByGroup.get(groupId) ?? []; + const allChecked = groupUnits.every((u) => checkedUnitIds.has(u.id)); + const next = new Set(checkedUnitIds); + for (const u of groupUnits) { + if (allChecked) next.delete(u.id); + else next.add(u.id); + } + checkedUnitIds = next; + } + + function groupAllChecked(groupId: string | null): boolean { + const units = unitsByGroup.get(groupId) ?? []; + return units.length > 0 && units.every((u) => checkedUnitIds.has(u.id)); + } + + function groupSomeChecked(groupId: string | null): boolean { + const units = unitsByGroup.get(groupId) ?? []; + return units.some((u) => checkedUnitIds.has(u.id)) && !groupAllChecked(groupId); + } + + // ── Bulk move ──────────────────────────────────────────────────────────────── + async function executeBulkAction() { + if (bulkAction === 'move') { + if (!bulkMoveTargetGroupId || checkedUnitIds.size === 0) return; + formError = null; + const results = await Promise.all( + [...checkedUnitIds].map((unitId) => + fetch(`/admin/api/units/${unitId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group: bulkMoveTargetGroupId }) + }) + ) + ); + const failed = results.filter((r) => !r.ok); + if (failed.length) formError = `${failed.length} unit(s) failed to move`; + checkedUnitIds = new Set(); + bulkAction = ''; + bulkMoveTargetGroupId = ''; + await loadData(); + } + } + + // ── Unit save/delete ───────────────────────────────────────────────────────── async function saveUnit() { formError = null; formSuccess = null; try { - let res: Response; - if (unitFormNew) { - res = await fetch('/admin/api/units', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(unitForm) - }); - } else { - res = await fetch(`/admin/api/units/${selectedItem?.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(unitForm) - }); - } - if (!res.ok) { - formError = await extractError(res, 'Save failed'); - return; - } + const res = unitFormNew + ? await fetch('/admin/api/units', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(unitForm) + }) + : await fetch(`/admin/api/units/${selectedItem?.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(unitForm) + }); + if (!res.ok) { formError = await extractError(res, 'Save failed'); return; } formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.'; if (unitFormNew) { const created = (await res.json()) as Unit; @@ -129,9 +167,7 @@ selectedItem = { type: 'unit', id: created.id ?? '' }; } await loadData(); - } catch { - formError = 'Network error'; - } + } catch { formError = 'Network error'; } } async function deleteUnit() { @@ -139,41 +175,30 @@ if (!confirm(`Delete unit "${unitForm.label}"?`)) return; try { const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' }); - if (!res.ok) { - formError = await extractError(res, 'Delete failed'); - return; - } + if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; } selectedItem = null; unitForm = {}; await loadData(); - } catch { - formError = 'Network error'; - } + } catch { formError = 'Network error'; } } - // ── Group form submit ──────────────────────────────────────────────────────── + // ── Group save/delete ───────────────────────────────────────────────────────── async function saveGroup() { formError = null; formSuccess = null; try { - let res: Response; - if (groupFormNew) { - res = await fetch('/admin/api/groups', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(groupForm) - }); - } else { - res = await fetch(`/admin/api/groups/${selectedItem?.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(groupForm) - }); - } - if (!res.ok) { - formError = await extractError(res, 'Save failed'); - return; - } + const res = groupFormNew + ? await fetch('/admin/api/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(groupForm) + }) + : await fetch(`/admin/api/groups/${selectedItem?.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(groupForm) + }); + if (!res.ok) { formError = await extractError(res, 'Save failed'); return; } formSuccess = groupFormNew ? 'Group created.' : 'Group saved.'; if (groupFormNew) { const created = (await res.json()) as Group; @@ -181,12 +206,9 @@ selectedItem = { type: 'group', id: created.id ?? '' }; } await loadData(); - } catch { - formError = 'Network error'; - } + } catch { formError = 'Network error'; } } - // ── Delete group modal ─────────────────────────────────────────────────────── function openDeleteGroup(group: Group) { deleteGroupTarget = group; deleteGroupAction = 'reassign'; @@ -195,83 +217,36 @@ formError = null; } - function closeDeleteGroup() { - deleteGroupTarget = null; - } + function closeDeleteGroup() { deleteGroupTarget = null; } async function confirmDeleteGroup() { if (!deleteGroupTarget) return; const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length; - - let body: Record; - if (unitsCount === 0) { - body = { action: 'orphan' }; - } else if (deleteGroupAction === 'reassign') { - body = { - action: 'reassign', - targetGroupId: deleteGroupTargetId, - toBaseAction: deleteGroupToBaseAction - }; - } else { - body = { action: 'orphan' }; - } - + const body: Record = unitsCount === 0 || deleteGroupAction === 'orphan' + ? { action: 'orphan' } + : { action: 'reassign', targetGroupId: deleteGroupTargetId, toBaseAction: deleteGroupToBaseAction }; try { const res = await fetch(`/admin/api/groups/${deleteGroupTarget.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - if (!res.ok) { - formError = await extractError(res, 'Delete failed'); - return; - } + if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; } deleteGroupTarget = null; selectedItem = null; groupForm = {}; await loadData(); - } catch { - formError = 'Network error'; - } + } catch { formError = 'Network error'; } } - // ── Multi-select move ──────────────────────────────────────────────────────── - async function moveCheckedUnits() { - if (!moveTargetGroupId || checkedUnitIds.size === 0) return; - formError = null; - const results = await Promise.all( - [...checkedUnitIds].map((unitId) => - fetch(`/admin/api/units/${unitId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group: moveTargetGroupId }) - }) - ) - ); - const failed = results.filter((r) => !r.ok); - if (failed.length) { - formError = `${failed.length} unit(s) failed to move`; - } - checkedUnitIds = new Set(); - moveTargetGroupId = ''; - await loadData(); - } - - function toggleCheck(unitId: string) { - const next = new Set(checkedUnitIds); - if (next.has(unitId)) next.delete(unitId); - else next.add(unitId); - checkedUnitIds = next; - } - - // ── Live toBase hint ───────────────────────────────────────────────────────── + // ── toBase hint ────────────────────────────────────────────────────────────── let toBaseHint = $derived.by(() => { const g = unitsData.groups.find((gr) => gr.id === unitForm.group); if (!g) return null; const base = unitsData.units.find((u) => u.id === g.baseUnitId); - const sym = unitForm.symbol ?? '?'; + const sym = unitForm.symbol || '?'; const tb = unitForm.toBase ?? 1; - const baseSymbol = base?.symbol ?? 'base units'; + const baseSymbol = base?.symbol ?? 'base'; return `1 ${sym} = ${tb} ${baseSymbol}`; }); @@ -290,81 +265,128 @@ -{#if checkedUnitIds.size > 0} -
- {checkedUnitIds.size} unit{checkedUnitIds.size === 1 ? '' : 's'} selected - - - -
-{/if} -
- + - +
{#if formError} - + {/if} {#if formSuccess} -

{formSuccess}

+

{formSuccess}

{/if} - {#if (selectedItem?.type === 'unit' || unitFormNew)} - + {#if selectedItem?.type === 'unit' || unitFormNew}

{unitFormNew ? 'New Unit' : 'Edit Unit'}

- + {#if !unitFormNew} {/if}
- {:else if (selectedItem?.type === 'group' || groupFormNew)} - + {:else if selectedItem?.type === 'group' || groupFormNew}

{groupFormNew ? 'New Group' : 'Edit Group'}

- + {#if !groupFormNew}
@@ -507,21 +514,14 @@