refactor: admin left panel — wider, less cluttered, persistent bulk action bar

- Left panel widened to 340px (was 280px)
- Removed redundant edit pencil icons from unit rows — clicking the row selects it
- Group names truncate with ellipsis instead of wrapping
- Added select-all/none checkbox per group header (with indeterminate state)
- Bulk action bar is now persistent at top of left panel, always visible
- Bulk actions use two-dropdown pattern: Action (Move to group) → Target group
- 'Add unit' text shortened to '+ Add unit', no more wrapping
- Delete-group orphan label clarified: 'Delete group only — keep units as Ungrouped'
This commit is contained in:
Falkan
2026-03-17 15:45:11 -04:00
parent c2179b69cc
commit ac6af9e90d

View File

@@ -5,7 +5,8 @@
let unitsData = $state<UnitsData>({ units: [], groups: [] }); let unitsData = $state<UnitsData>({ units: [], groups: [] });
let selectedItem = $state<{ type: 'unit' | 'group'; id: string } | null>(null); let selectedItem = $state<{ type: 'unit' | 'group'; id: string } | null>(null);
let checkedUnitIds = $state<Set<string>>(new Set()); let checkedUnitIds = $state<Set<string>>(new Set());
let moveTargetGroupId = $state<string>(''); let bulkAction = $state<string>(''); // '' | 'move'
let bulkMoveTargetGroupId = $state<string>('');
let expandedGroups = $state<Set<string>>(new Set()); let expandedGroups = $state<Set<string>>(new Set());
let deleteGroupTarget = $state<Group | null>(null); let deleteGroupTarget = $state<Group | null>(null);
let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign'); let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign');
@@ -22,7 +23,7 @@
let groupForm = $state<Partial<Group>>({}); let groupForm = $state<Partial<Group>>({});
let groupFormNew = $state(false); let groupFormNew = $state(false);
// ── Load data on mount ─────────────────────────────────────────────────────── // ── Load data ────────────────────────────────────────────────────────────────
async function loadData() { async function loadData() {
const [res, gres] = await Promise.all([ const [res, gres] = await Promise.all([
fetch('/admin/api/units'), fetch('/admin/api/units'),
@@ -32,16 +33,11 @@
const units: Unit[] = await res.json(); const units: Unit[] = await res.json();
const groups: Group[] = gres.ok ? await gres.json() : []; const groups: Group[] = gres.ok ? await gres.json() : [];
unitsData = { units, groups }; unitsData = { units, groups };
// Expand all groups — use assignment for consistent reactive update
expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]); 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<string> { async function extractError(res: Response, fallback: string): Promise<string> {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
return (body as Record<string, string>).error ?? fallback; return (body as Record<string, string>).error ?? fallback;
@@ -58,10 +54,6 @@
return map; return map;
}); });
function getBaseUnit(group: Group): Unit | undefined {
return unitsData.units.find((u) => u.id === group.baseUnitId);
}
// ── Selection ──────────────────────────────────────────────────────────────── // ── Selection ────────────────────────────────────────────────────────────────
function selectUnit(unit: Unit) { function selectUnit(unit: Unit) {
selectedItem = { type: 'unit', id: unit.id }; selectedItem = { type: 'unit', id: unit.id };
@@ -99,29 +91,75 @@
formSuccess = null; 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() { async function saveUnit() {
formError = null; formError = null;
formSuccess = null; formSuccess = null;
try { try {
let res: Response; const res = unitFormNew
if (unitFormNew) { ? await fetch('/admin/api/units', {
res = await fetch('/admin/api/units', { method: 'POST',
method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(unitForm)
body: JSON.stringify(unitForm) })
}); : await fetch(`/admin/api/units/${selectedItem?.id}`, {
} else { method: 'PUT',
res = await fetch(`/admin/api/units/${selectedItem?.id}`, { headers: { 'Content-Type': 'application/json' },
method: 'PUT', body: JSON.stringify(unitForm)
headers: { 'Content-Type': 'application/json' }, });
body: JSON.stringify(unitForm) if (!res.ok) { formError = await extractError(res, 'Save failed'); return; }
});
}
if (!res.ok) {
formError = await extractError(res, 'Save failed');
return;
}
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.'; formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
if (unitFormNew) { if (unitFormNew) {
const created = (await res.json()) as Unit; const created = (await res.json()) as Unit;
@@ -129,9 +167,7 @@
selectedItem = { type: 'unit', id: created.id ?? '' }; selectedItem = { type: 'unit', id: created.id ?? '' };
} }
await loadData(); await loadData();
} catch { } catch { formError = 'Network error'; }
formError = 'Network error';
}
} }
async function deleteUnit() { async function deleteUnit() {
@@ -139,41 +175,30 @@
if (!confirm(`Delete unit "${unitForm.label}"?`)) return; if (!confirm(`Delete unit "${unitForm.label}"?`)) return;
try { try {
const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' }); const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' });
if (!res.ok) { if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; }
formError = await extractError(res, 'Delete failed');
return;
}
selectedItem = null; selectedItem = null;
unitForm = {}; unitForm = {};
await loadData(); await loadData();
} catch { } catch { formError = 'Network error'; }
formError = 'Network error';
}
} }
// ── Group form submit ──────────────────────────────────────────────────────── // ── Group save/delete ─────────────────────────────────────────────────────────
async function saveGroup() { async function saveGroup() {
formError = null; formError = null;
formSuccess = null; formSuccess = null;
try { try {
let res: Response; const res = groupFormNew
if (groupFormNew) { ? await fetch('/admin/api/groups', {
res = await fetch('/admin/api/groups', { method: 'POST',
method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(groupForm)
body: JSON.stringify(groupForm) })
}); : await fetch(`/admin/api/groups/${selectedItem?.id}`, {
} else { method: 'PUT',
res = await fetch(`/admin/api/groups/${selectedItem?.id}`, { headers: { 'Content-Type': 'application/json' },
method: 'PUT', body: JSON.stringify(groupForm)
headers: { 'Content-Type': 'application/json' }, });
body: JSON.stringify(groupForm) if (!res.ok) { formError = await extractError(res, 'Save failed'); return; }
});
}
if (!res.ok) {
formError = await extractError(res, 'Save failed');
return;
}
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.'; formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
if (groupFormNew) { if (groupFormNew) {
const created = (await res.json()) as Group; const created = (await res.json()) as Group;
@@ -181,12 +206,9 @@
selectedItem = { type: 'group', id: created.id ?? '' }; selectedItem = { type: 'group', id: created.id ?? '' };
} }
await loadData(); await loadData();
} catch { } catch { formError = 'Network error'; }
formError = 'Network error';
}
} }
// ── Delete group modal ───────────────────────────────────────────────────────
function openDeleteGroup(group: Group) { function openDeleteGroup(group: Group) {
deleteGroupTarget = group; deleteGroupTarget = group;
deleteGroupAction = 'reassign'; deleteGroupAction = 'reassign';
@@ -195,83 +217,36 @@
formError = null; formError = null;
} }
function closeDeleteGroup() { function closeDeleteGroup() { deleteGroupTarget = null; }
deleteGroupTarget = null;
}
async function confirmDeleteGroup() { async function confirmDeleteGroup() {
if (!deleteGroupTarget) return; if (!deleteGroupTarget) return;
const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length; const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length;
const body: Record<string, unknown> = unitsCount === 0 || deleteGroupAction === 'orphan'
let body: Record<string, unknown>; ? { action: 'orphan' }
if (unitsCount === 0) { : { action: 'reassign', targetGroupId: deleteGroupTargetId, toBaseAction: deleteGroupToBaseAction };
body = { action: 'orphan' };
} else if (deleteGroupAction === 'reassign') {
body = {
action: 'reassign',
targetGroupId: deleteGroupTargetId,
toBaseAction: deleteGroupToBaseAction
};
} else {
body = { action: 'orphan' };
}
try { try {
const res = await fetch(`/admin/api/groups/${deleteGroupTarget.id}`, { const res = await fetch(`/admin/api/groups/${deleteGroupTarget.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (!res.ok) { if (!res.ok) { formError = await extractError(res, 'Delete failed'); return; }
formError = await extractError(res, 'Delete failed');
return;
}
deleteGroupTarget = null; deleteGroupTarget = null;
selectedItem = null; selectedItem = null;
groupForm = {}; groupForm = {};
await loadData(); await loadData();
} catch { } catch { formError = 'Network error'; }
formError = 'Network error';
}
} }
// ── Multi-select move ──────────────────────────────────────────────────────── // ── toBase hint ──────────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────
let toBaseHint = $derived.by(() => { let toBaseHint = $derived.by(() => {
const g = unitsData.groups.find((gr) => gr.id === unitForm.group); const g = unitsData.groups.find((gr) => gr.id === unitForm.group);
if (!g) return null; if (!g) return null;
const base = unitsData.units.find((u) => u.id === g.baseUnitId); const base = unitsData.units.find((u) => u.id === g.baseUnitId);
const sym = unitForm.symbol ?? '?'; const sym = unitForm.symbol || '?';
const tb = unitForm.toBase ?? 1; const tb = unitForm.toBase ?? 1;
const baseSymbol = base?.symbol ?? 'base units'; const baseSymbol = base?.symbol ?? 'base';
return `1 ${sym} = ${tb} ${baseSymbol}`; return `1 ${sym} = ${tb} ${baseSymbol}`;
}); });
@@ -290,81 +265,128 @@
<button class="outline secondary logout-btn" onclick={logout}>Log out</button> <button class="outline secondary logout-btn" onclick={logout}>Log out</button>
</div> </div>
{#if checkedUnitIds.size > 0}
<div class="bulk-actions">
<span>{checkedUnitIds.size} unit{checkedUnitIds.size === 1 ? '' : 's'} selected</span>
<select bind:value={moveTargetGroupId}>
<option value="">Move to group…</option>
{#each unitsData.groups as g}
<option value={g.id}>{g.label}</option>
{/each}
</select>
<button onclick={moveCheckedUnits} disabled={!moveTargetGroupId}>Move</button>
<button class="outline secondary" onclick={() => (checkedUnitIds = new Set())}>Clear</button>
</div>
{/if}
<div class="admin-panels"> <div class="admin-panels">
<!-- Left panel: tree --> <!-- ── Left panel ─────────────────────────────────────────────────────────── -->
<aside class="admin-tree"> <aside class="admin-tree">
<!-- Persistent bulk-action bar -->
<div class="bulk-bar">
<span class="bulk-count">
{#if checkedUnitIds.size > 0}
{checkedUnitIds.size} selected
{:else}
0 selected
{/if}
</span>
<div class="bulk-controls">
<select
bind:value={bulkAction}
disabled={checkedUnitIds.size === 0}
aria-label="Bulk action"
>
<option value="">Action…</option>
<option value="move">Move to group</option>
</select>
{#if bulkAction === 'move'}
<select bind:value={bulkMoveTargetGroupId} aria-label="Target group">
<option value="">Group…</option>
{#each unitsData.groups as g}
<option value={g.id}>{g.label}</option>
{/each}
<option value="__orphan__">Ungrouped</option>
</select>
{/if}
<button
class="bulk-go"
disabled={checkedUnitIds.size === 0 || !bulkAction || (bulkAction === 'move' && !bulkMoveTargetGroupId)}
onclick={executeBulkAction}
>Go</button>
{#if checkedUnitIds.size > 0}
<button class="bulk-clear plain" onclick={() => { checkedUnitIds = new Set(); bulkAction = ''; bulkMoveTargetGroupId = ''; }} title="Clear selection"></button>
{/if}
</div>
</div>
<!-- Group tree -->
{#each unitsData.groups as group (group.id)} {#each unitsData.groups as group (group.id)}
{@const groupUnits = unitsByGroup.get(group.id) ?? []} {@const groupUnits = unitsByGroup.get(group.id) ?? []}
<div class="tree-group"> <div class="tree-group">
<div class="tree-group-header"> <div class="tree-group-header">
<!-- Expand toggle -->
<button <button
class="tree-expand plain" class="plain expand-btn"
onclick={() => { onclick={() => {
const next = new Set(expandedGroups); const next = new Set(expandedGroups);
if (next.has(group.id)) next.delete(group.id); if (next.has(group.id)) next.delete(group.id);
else next.add(group.id); else next.add(group.id);
expandedGroups = next; expandedGroups = next;
}} }}
> aria-label={expandedGroups.has(group.id) ? 'Collapse' : 'Expand'}
{expandedGroups.has(group.id) ? '▾' : '▸'} >{expandedGroups.has(group.id) ? '▾' : '▸'}</button>
</button>
<button class="tree-group-label plain" onclick={() => selectGroup(group)}> <!-- Select-all checkbox -->
<input
class="group-check"
type="checkbox"
checked={groupAllChecked(group.id)}
indeterminate={groupSomeChecked(group.id)}
onchange={() => toggleGroupCheck(group.id)}
title="Select all units in this group"
/>
<!-- Group name (click to edit) -->
<button class="plain tree-group-name" onclick={() => selectGroup(group)}>
<strong>{group.label}</strong> <strong>{group.label}</strong>
<small class="muted">{groupUnits.length} unit{groupUnits.length === 1 ? '' : 's'}</small> <small class="muted"> · {groupUnits.length} unit{groupUnits.length === 1 ? '' : 's'}</small>
</button> </button>
<div class="tree-group-actions">
<button class="icon-btn plain" title="Edit group" onclick={() => selectGroup(group)}>✏️</button> <!-- Delete only — edit is via click on name -->
<button class="icon-btn plain" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑</button> <button class="plain icon-btn" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑</button>
</div>
</div> </div>
{#if expandedGroups.has(group.id)} {#if expandedGroups.has(group.id)}
<div class="tree-units"> <div class="tree-units">
{#each groupUnits as unit (unit.id)} {#each groupUnits as unit (unit.id)}
<div class="tree-unit-row" class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}> <div
class="tree-unit-row"
class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}
>
<input <input
type="checkbox" type="checkbox"
checked={checkedUnitIds.has(unit.id)} checked={checkedUnitIds.has(unit.id)}
onchange={() => toggleCheck(unit.id)} onchange={() => toggleCheck(unit.id)}
/> />
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}> <button class="plain tree-unit-btn" onclick={() => selectUnit(unit)}>
{unit.label} {unit.label}
<span class="muted">({unit.symbol})</span> <span class="muted">({unit.symbol})</span>
{#if unit.id === group.baseUnitId} {#if unit.id === group.baseUnitId}
<span class="base-badge" title="Base unit"></span> <span class="base-badge" title="Base unit"></span>
{/if} {/if}
</button> </button>
<button class="icon-btn plain" title="Edit" onclick={() => selectUnit(unit)}>✏️</button>
</div> </div>
{/each} {/each}
<button class="add-unit-btn plain" onclick={() => startNewUnit(group.id)}> <button class="plain add-unit-btn" onclick={() => startNewUnit(group.id)}>
+ Add Unit to {group.label} + Add unit
</button> </button>
</div> </div>
{/if} {/if}
</div> </div>
{/each} {/each}
<!-- Ungrouped section --> <!-- Ungrouped -->
{#if (unitsByGroup.get(null) ?? []).length > 0} {#if (unitsByGroup.get(null) ?? []).length > 0}
{@const orphaned = unitsByGroup.get(null) ?? []} {@const orphaned = unitsByGroup.get(null) ?? []}
<div class="tree-group"> <div class="tree-group">
<div class="tree-group-header"> <div class="tree-group-header">
<span class="tree-group-label"><strong>Ungrouped</strong> <small class="muted">{orphaned.length}</small></span> <input
class="group-check"
type="checkbox"
checked={groupAllChecked(null)}
indeterminate={groupSomeChecked(null)}
onchange={() => toggleGroupCheck(null)}
title="Select all ungrouped units"
/>
<span class="tree-group-name"><strong>Ungrouped</strong> <small class="muted">· {orphaned.length}</small></span>
</div> </div>
<div class="tree-units"> <div class="tree-units">
{#each orphaned as unit (unit.id)} {#each orphaned as unit (unit.id)}
@@ -374,10 +396,9 @@
checked={checkedUnitIds.has(unit.id)} checked={checkedUnitIds.has(unit.id)}
onchange={() => toggleCheck(unit.id)} onchange={() => toggleCheck(unit.id)}
/> />
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}> <button class="plain tree-unit-btn" onclick={() => selectUnit(unit)}>
{unit.label} <span class="muted">({unit.symbol})</span> {unit.label} <span class="muted">({unit.symbol})</span>
</button> </button>
<button class="icon-btn plain" onclick={() => selectUnit(unit)}>✏️</button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -390,26 +411,20 @@
</div> </div>
</aside> </aside>
<!-- Right panel: form --> <!-- ── Right panel ────────────────────────────────────────────────────────── -->
<section class="admin-form-panel"> <section class="admin-form-panel">
{#if formError} {#if formError}
<p class="form-error" role="alert">{formError}</p> <p class="form-msg form-error" role="alert">{formError}</p>
{/if} {/if}
{#if formSuccess} {#if formSuccess}
<p class="form-success" role="status">{formSuccess}</p> <p class="form-msg form-success" role="status">{formSuccess}</p>
{/if} {/if}
{#if (selectedItem?.type === 'unit' || unitFormNew)} {#if selectedItem?.type === 'unit' || unitFormNew}
<!-- Unit form -->
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3> <h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
<label> <label>
ID {#if !unitFormNew}<small>(readonly)</small>{/if} ID {#if !unitFormNew}<small class="muted">(readonly)</small>{/if}
<input <input type="text" bind:value={unitForm.id} readonly={!unitFormNew} placeholder="e.g. my-unit" />
type="text"
bind:value={unitForm.id}
readonly={!unitFormNew}
placeholder="e.g. my-unit"
/>
</label> </label>
<label> <label>
Label Label
@@ -440,23 +455,17 @@
{/if} {/if}
</label> </label>
<div class="form-actions"> <div class="form-actions">
<button onclick={saveUnit}>{unitFormNew ? 'Create Unit' : 'Save Unit'}</button> <button onclick={saveUnit}>{unitFormNew ? 'Create' : 'Save'}</button>
{#if !unitFormNew} {#if !unitFormNew}
<button class="outline secondary" onclick={deleteUnit}>Delete</button> <button class="outline secondary" onclick={deleteUnit}>Delete</button>
{/if} {/if}
</div> </div>
{:else if (selectedItem?.type === 'group' || groupFormNew)} {:else if selectedItem?.type === 'group' || groupFormNew}
<!-- Group form -->
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3> <h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
<label> <label>
ID {#if !groupFormNew}<small>(readonly)</small>{/if} ID {#if !groupFormNew}<small class="muted">(readonly)</small>{/if}
<input <input type="text" bind:value={groupForm.id} readonly={!groupFormNew} placeholder="e.g. my-group" />
type="text"
bind:value={groupForm.id}
readonly={!groupFormNew}
placeholder="e.g. my-group"
/>
</label> </label>
<label> <label>
Label Label
@@ -464,7 +473,7 @@
</label> </label>
<label> <label>
Base Unit Base Unit
<small>Changing the base unit will auto-recalculate all toBase values in this group.</small> <small class="muted">Changing this auto-recalculates all toBase values in the group.</small>
<select bind:value={groupForm.baseUnitId}> <select bind:value={groupForm.baseUnitId}>
<option value="">None</option> <option value="">None</option>
{#each unitsData.units.filter((u) => u.group === groupForm.id) as u} {#each unitsData.units.filter((u) => u.group === groupForm.id) as u}
@@ -474,13 +483,11 @@
</label> </label>
<label> <label>
To Universal To Universal
<small title="Relative scale for cross-group YOLO conversions. Arbitrary — make it up."> <small class="muted">Relative scale for cross-group (YOLO) conversions. Arbitrary.</small>
Relative scale for cross-group conversions.
</small>
<input type="number" bind:value={groupForm.toUniversal} min="0.000001" step="any" /> <input type="number" bind:value={groupForm.toUniversal} min="0.000001" step="any" />
</label> </label>
<div class="form-actions"> <div class="form-actions">
<button onclick={saveGroup}>{groupFormNew ? 'Create Group' : 'Save Group'}</button> <button onclick={saveGroup}>{groupFormNew ? 'Create' : 'Save'}</button>
{#if !groupFormNew} {#if !groupFormNew}
<button <button
class="outline secondary" class="outline secondary"
@@ -493,7 +500,7 @@
</div> </div>
{:else} {:else}
<p class="select-hint">Select a unit or group to edit</p> <p class="select-hint">Select a unit or group to edit, or add a new one.</p>
{/if} {/if}
</section> </section>
</div> </div>
@@ -507,21 +514,14 @@
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<article class="modal-card" onclick={(e) => e.stopPropagation()}> <article class="modal-card" onclick={(e) => e.stopPropagation()}>
<h3>Delete "{deleteGroupTarget.label}"</h3> <h3>Delete "{deleteGroupTarget.label}"</h3>
{#if unitsCount === 0} {#if unitsCount === 0}
<p>This group has no units. Delete it?</p> <p>This group has no units. Delete it?</p>
<div class="form-actions"> <div class="form-actions">
<button <button onclick={confirmDeleteGroup}>Delete Group</button>
onclick={() => {
deleteGroupAction = 'orphan';
confirmDeleteGroup();
}}
>Delete Group</button>
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button> <button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
</div> </div>
{:else} {:else}
<p>This group contains <strong>{unitsCount}</strong> unit{unitsCount === 1 ? '' : 's'}.</p> <p>This group contains <strong>{unitsCount}</strong> unit{unitsCount === 1 ? '' : 's'}.</p>
<fieldset> <fieldset>
<legend>What to do with these units?</legend> <legend>What to do with these units?</legend>
<label> <label>
@@ -529,24 +529,25 @@
Reassign to another group Reassign to another group
</label> </label>
{#if deleteGroupAction === 'reassign'} {#if deleteGroupAction === 'reassign'}
<select bind:value={deleteGroupTargetId} style="margin: 0.5rem 0;"> <div class="reassign-options">
<option value="">Select target group…</option> <select bind:value={deleteGroupTargetId}>
{#each unitsData.groups.filter((g) => g.id !== deleteGroupTarget?.id) as g} <option value="">Select target group…</option>
<option value={g.id}>{g.label}</option> {#each unitsData.groups.filter((g) => g.id !== deleteGroupTarget?.id) as g}
{/each} <option value={g.id}>{g.label}</option>
</select> {/each}
<div> </select>
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="keep" /> Keep toBase values</label> <div class="radio-stack">
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="recalculate" /> Recalculate (scale by toUniversal ratio)</label> <label><input type="radio" bind:group={deleteGroupToBaseAction} value="keep" /> Keep toBase values unchanged</label>
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="reset" /> Reset to 1.0</label> <label><input type="radio" bind:group={deleteGroupToBaseAction} value="recalculate" /> Recalculate via toUniversal ratio</label>
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="reset" /> Reset all toBase to 1.0</label>
</div>
</div> </div>
{/if} {/if}
<label> <label>
<input type="radio" bind:group={deleteGroupAction} value="orphan" /> <input type="radio" bind:group={deleteGroupAction} value="orphan" />
Delete Group, Keep Units (Orphan) Delete group only — keep units as Ungrouped
</label> </label>
</fieldset> </fieldset>
<div class="form-actions"> <div class="form-actions">
<button <button
onclick={confirmDeleteGroup} onclick={confirmDeleteGroup}
@@ -568,83 +569,194 @@
justify-content: space-between; justify-content: space-between;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.admin-header h2 { margin: 0; }
.admin-header h2 {
margin: 0;
}
.bulk-actions {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
}
.admin-panels { .admin-panels {
display: grid; display: grid;
grid-template-columns: 280px 1fr; grid-template-columns: 340px 1fr;
gap: 1.5rem; gap: 1.5rem;
align-items: start; align-items: start;
} }
@media (max-width: 800px) {
@media (max-width: 720px) {
.admin-panels { grid-template-columns: 1fr; } .admin-panels { grid-template-columns: 1fr; }
} }
/* ── Left panel ────────────────────────────────────────────────────────────── */
.admin-tree { .admin-tree {
background: var(--pico-card-background-color); background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color); border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius); border-radius: var(--pico-border-radius);
padding: 0.75rem; overflow: hidden; /* clean edges */
} }
.tree-group { margin-bottom: 0.5rem; } /* Persistent bulk-action bar */
.bulk-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--pico-card-border-color);
background: var(--pico-secondary-background, rgba(0,0,0,0.03));
min-height: 2.75rem;
}
.bulk-count {
font-size: 0.8rem;
color: var(--pico-muted-color);
white-space: nowrap;
min-width: 5rem;
}
.bulk-controls {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1;
}
.bulk-controls select {
flex: 1;
min-width: 0;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
margin: 0;
height: 2rem;
}
.bulk-go {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
margin: 0;
white-space: nowrap;
height: 2rem;
}
.bulk-clear {
font-size: 0.75rem;
color: var(--pico-muted-color);
padding: 0.15rem 0.3rem;
}
.bulk-clear:hover { color: var(--pico-color); }
/* Tree groups */
.tree-group {
border-bottom: 1px solid var(--pico-card-border-color);
}
.tree-group:last-of-type { border-bottom: none; }
.tree-group-header { .tree-group-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem;
}
.tree-group-label {
flex: 1;
text-align: left;
display: flex;
gap: 0.4rem; gap: 0.4rem;
align-items: baseline; padding: 0.5rem 0.75rem;
} }
.tree-group-actions { .expand-btn {
display: flex; color: var(--pico-muted-color);
gap: 0.25rem; font-size: 0.75rem;
flex-shrink: 0;
width: 1.2rem;
text-align: center;
} }
.group-check {
flex-shrink: 0;
margin: 0;
width: 1rem;
height: 1rem;
cursor: pointer;
}
.tree-group-name {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
/* Tree units */
.tree-units { .tree-units {
margin-left: 1.5rem; padding: 0.25rem 0.75rem 0.5rem 2.8rem;
margin-top: 0.25rem;
} }
.tree-unit-row { .tree-unit-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.5rem;
padding: 0.15rem 0.25rem; padding: 0.2rem 0.3rem;
border-radius: var(--pico-border-radius); border-radius: calc(var(--pico-border-radius) / 2);
} }
.tree-unit-row.selected { .tree-unit-row.selected {
background: var(--pico-primary-background); background: var(--pico-primary-background);
} }
.tree-unit-row input[type=checkbox] {
.tree-unit-label { flex-shrink: 0;
margin: 0;
width: 1rem;
height: 1rem;
cursor: pointer;
}
.tree-unit-btn {
flex: 1; flex: 1;
text-align: left; text-align: left;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.add-unit-btn {
font-size: 0.8rem;
color: var(--pico-primary);
margin-top: 0.25rem;
padding: 0.15rem 0;
}
.tree-footer {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--pico-card-border-color);
}
.tree-footer button {
flex: 1;
font-size: 0.85rem;
margin: 0;
padding: 0.4rem 0.5rem;
}
/* ── Right panel ───────────────────────────────────────────────────────────── */
.admin-form-panel {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
padding: 1.25rem;
}
.select-hint {
color: var(--pico-muted-color);
font-style: italic;
margin: 2rem 0;
text-align: center;
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.form-msg {
padding: 0.5rem 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.form-error { color: var(--pico-del-color, #e74c3c); background: rgba(231,76,60,0.1); }
.form-success { color: var(--pico-ins-color, #2ecc71); background: rgba(46,204,113,0.1); }
.tobase-hint {
display: block;
color: var(--pico-muted-color);
margin-top: 0.25rem;
font-style: italic;
}
/* ── Shared ────────────────────────────────────────────────────────────────── */
.plain { .plain {
background: none; background: none;
border: none; border: none;
@@ -654,90 +766,13 @@
color: inherit; color: inherit;
font: inherit; font: inherit;
} }
.icon-btn { opacity: 0.5; font-size: 0.85rem; }
.icon-btn {
font-size: 0.9rem;
opacity: 0.6;
}
.icon-btn:hover { opacity: 1; } .icon-btn:hover { opacity: 1; }
.base-badge { color: var(--pico-primary); font-size: 0.75rem; margin-left: 0.2rem; }
.muted { color: var(--pico-muted-color); font-size: 0.85rem; }
.logout-btn { width: auto; padding: 0.3rem 0.75rem; margin-bottom: 0; font-size: 0.9rem; }
.base-badge { /* ── Modal ─────────────────────────────────────────────────────────────────── */
color: var(--pico-primary);
font-size: 0.8rem;
}
.muted {
color: var(--pico-muted-color);
font-size: 0.85rem;
}
.add-unit-btn {
font-size: 0.85rem;
color: var(--pico-primary);
padding: 0.2rem 0.25rem;
margin-top: 0.25rem;
}
.tree-footer {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--pico-card-border-color);
}
.admin-form-panel {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
padding: 1.25rem;
}
.select-hint {
color: var(--pico-muted-color);
font-style: italic;
margin: 2rem 0;
text-align: center;
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.form-error {
color: var(--pico-del-color, #e74c3c);
background: rgba(231, 76, 60, 0.1);
padding: 0.5rem 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.9rem;
}
.form-success {
color: var(--pico-ins-color, #2ecc71);
background: rgba(46, 204, 113, 0.1);
padding: 0.5rem 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.9rem;
}
.tobase-hint {
display: block;
color: var(--pico-muted-color);
margin-top: 0.25rem;
font-style: italic;
}
.logout-btn {
width: auto;
padding: 0.3rem 0.75rem;
margin-bottom: 0;
font-size: 0.9rem;
}
/* Modal */
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -747,10 +782,7 @@
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
} }
.modal-card { width: 100%; max-width: 480px; margin: 0 1rem; }
.modal-card { .reassign-options { margin: 0.5rem 0 0.5rem 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; }
width: 100%; .radio-stack { display: flex; flex-direction: column; gap: 0.25rem; }
max-width: 480px;
margin: 0 1rem;
}
</style> </style>