v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
|
||||
import SortHeader from '$lib/table/SortHeader.svelte';
|
||||
import { TableController } from '$lib/table/table.svelte';
|
||||
import type {
|
||||
@@ -11,7 +12,7 @@
|
||||
EditorMixUpdateInput,
|
||||
RawMaterial
|
||||
} from '$lib/types';
|
||||
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
|
||||
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -42,6 +43,9 @@
|
||||
// rescales every row's kg from its %.
|
||||
let totalReference = $state(0);
|
||||
|
||||
// The mix whose change history is open in the modal (null = closed).
|
||||
let historyMix = $state<EditableRow | null>(null);
|
||||
|
||||
// Inline "create new mix" form state.
|
||||
let creatingMix = $state(false);
|
||||
let newMixClient = $state('');
|
||||
@@ -531,6 +535,10 @@
|
||||
<FlaskConical size={16} strokeWidth={2.2} />
|
||||
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
|
||||
</button>
|
||||
<button class="clear-button" type="button" onclick={() => (historyMix = row)} aria-label={`History for ${row.name}`}>
|
||||
<History size={16} strokeWidth={2.2} />
|
||||
History
|
||||
</button>
|
||||
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
|
||||
<Save size={16} strokeWidth={2.4} />
|
||||
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
|
||||
@@ -617,6 +625,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if historyMix}
|
||||
<ChangeHistoryModal
|
||||
entityType="mix"
|
||||
entityId={historyMix.id}
|
||||
title={historyMix.name}
|
||||
subtitle={historyMix.client_name}
|
||||
onClose={() => (historyMix = null)}
|
||||
/>
|
||||
{/if}
|
||||
</AppSecondaryRailLayout>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
|
||||
import SortHeader from '$lib/table/SortHeader.svelte';
|
||||
import { TableController } from '$lib/table/table.svelte';
|
||||
import { formatNumber } from '$lib/format';
|
||||
import type { EditorIngredientRow } from '$lib/types';
|
||||
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
|
||||
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -37,6 +38,8 @@
|
||||
let query = $state('');
|
||||
let statusFilter = $state<'active' | 'archived' | 'all'>('active');
|
||||
let savingKey = $state<string | null>(null);
|
||||
// The ingredient whose change history is open in the modal (null = closed).
|
||||
let historyIngredient = $state<EditableIngredient | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (rows.length === 0 && (data.ingredients as EditorIngredientRow[]).length > 0) {
|
||||
@@ -380,6 +383,10 @@
|
||||
</div>
|
||||
|
||||
<div class="row-actions">
|
||||
<button class="clear-button" type="button" onclick={() => (historyIngredient = row)} aria-label={`History for ${row.name}`}>
|
||||
<History size={16} strokeWidth={2.2} />
|
||||
History
|
||||
</button>
|
||||
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
|
||||
<Save size={16} strokeWidth={2.4} />
|
||||
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save'}
|
||||
@@ -406,6 +413,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if historyIngredient}
|
||||
<ChangeHistoryModal
|
||||
entityType="ingredient"
|
||||
entityId={historyIngredient.id}
|
||||
title={historyIngredient.name}
|
||||
subtitle={historyIngredient.unit_of_measure}
|
||||
onClose={() => (historyIngredient = null)}
|
||||
/>
|
||||
{/if}
|
||||
</AppSecondaryRailLayout>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -2,18 +2,28 @@
|
||||
import { api } from '$lib/api';
|
||||
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||
import { clientSession } from '$lib/session';
|
||||
import RoleManagementPanel from '$lib/components/settings/RoleManagementPanel.svelte';
|
||||
import UserManagementPanel from '$lib/components/settings/UserManagementPanel.svelte';
|
||||
import { clientSession, hasPermission } from '$lib/session';
|
||||
import { canEditThroughput } from '$lib/workspace-access';
|
||||
import { toast } from '$lib/toast';
|
||||
import type { ThroughputImportResult } from '$lib/types';
|
||||
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
|
||||
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert, Trash2, Users } from 'lucide-svelte';
|
||||
|
||||
type Section = 'profile' | 'security' | 'import';
|
||||
type Section = 'profile' | 'security' | 'import' | 'users' | 'roles';
|
||||
let activeSection = $state<Section>('profile');
|
||||
|
||||
// Only operators who can edit throughput see (and can use) the import tool.
|
||||
const canImportThroughput = $derived(canEditThroughput($clientSession));
|
||||
|
||||
// Lean owners and admins (manage_users permission) get the User management
|
||||
// section. Visibility is a convenience — every endpoint enforces the
|
||||
// permission itself.
|
||||
const canManageUsers = $derived(hasPermission($clientSession, 'manage_users'));
|
||||
const canManageRoles = $derived(
|
||||
$clientSession?.role === 'internal' && ['lean', 'admin'].includes($clientSession?.role_name?.toLowerCase() ?? '')
|
||||
);
|
||||
|
||||
let name = $state($clientSession?.name ?? '');
|
||||
let email = $state($clientSession?.email ?? '');
|
||||
|
||||
@@ -117,6 +127,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete all throughput entries ─────────────────────────────
|
||||
// A destructive maintenance action — clearing a bad import in one go rather
|
||||
// than deleting runs one by one. Guarded behind a typed confirmation.
|
||||
let showDeleteAll = $state(false);
|
||||
let deleteAllConfirmText = $state('');
|
||||
let deletingAll = $state(false);
|
||||
let deleteAllResult = $state<number | null>(null);
|
||||
|
||||
function openDeleteAll() {
|
||||
deleteAllConfirmText = '';
|
||||
deleteAllResult = null;
|
||||
showDeleteAll = true;
|
||||
}
|
||||
|
||||
function cancelDeleteAll() {
|
||||
if (deletingAll) return;
|
||||
showDeleteAll = false;
|
||||
deleteAllConfirmText = '';
|
||||
}
|
||||
|
||||
async function confirmDeleteAll() {
|
||||
if (deleteAllConfirmText.trim().toUpperCase() !== 'DELETE') return;
|
||||
deletingAll = true;
|
||||
const tid = toast.loading('Deleting all entries…');
|
||||
try {
|
||||
const result = await api.deleteAllThroughputEntries();
|
||||
deleteAllResult = result.entries_deleted;
|
||||
showDeleteAll = false;
|
||||
deleteAllConfirmText = '';
|
||||
toast.dismiss(tid);
|
||||
toast.success(
|
||||
`Deleted ${result.entries_deleted} ${result.entries_deleted === 1 ? 'entry' : 'entries'}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete entries');
|
||||
} finally {
|
||||
deletingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a small sample CSV in the browser so operators have a working header
|
||||
// row to copy from. The backend matches these headers case-insensitively.
|
||||
function downloadTemplate() {
|
||||
@@ -171,6 +222,8 @@
|
||||
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
||||
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
||||
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
|
||||
...(canManageUsers ? [{ id: 'users' as Section, label: 'Users', icon: Users }] : []),
|
||||
...(canManageRoles ? [{ id: 'roles' as Section, label: 'Roles', icon: Users }] : []),
|
||||
]);
|
||||
|
||||
const railGroups = $derived([{ items: navItems }]);
|
||||
@@ -339,11 +392,81 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="danger-zone">
|
||||
<div class="danger-copy">
|
||||
<h3><TriangleAlert size={16} strokeWidth={2.2} /> Delete all entries</h3>
|
||||
<p>
|
||||
Permanently remove every throughput entry. Products are kept, but all
|
||||
packing runs are erased. This cannot be undone — use it to clear a bad
|
||||
import before re-uploading.
|
||||
</p>
|
||||
{#if deleteAllResult !== null}
|
||||
<p class="danger-result" role="status">
|
||||
Deleted <strong>{deleteAllResult}</strong>
|
||||
{deleteAllResult === 1 ? 'entry' : 'entries'}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="btn-danger" onclick={openDeleteAll}>
|
||||
<Trash2 size={15} strokeWidth={2.2} /> Delete all entries
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeSection === 'users' && canManageUsers}
|
||||
<UserManagementPanel />
|
||||
|
||||
{:else if activeSection === 'roles' && canManageRoles}
|
||||
<RoleManagementPanel />
|
||||
{/if}
|
||||
</div>
|
||||
</AppSecondaryRailLayout>
|
||||
|
||||
{#if showDeleteAll}
|
||||
<div class="modal-backdrop" role="presentation" onclick={cancelDeleteAll}>
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-all-title"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') cancelDeleteAll(); }}
|
||||
>
|
||||
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
|
||||
<h2 id="delete-all-title" class="modal-title">Delete all throughput entries?</h2>
|
||||
<p class="modal-text">
|
||||
This permanently removes <strong>every</strong> throughput entry for your
|
||||
workspace. Products are kept, but the packing-run history cannot be recovered.
|
||||
</p>
|
||||
<label class="modal-confirm-field">
|
||||
<span>Type <strong>DELETE</strong> to confirm</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={deleteAllConfirmText}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="DELETE"
|
||||
/>
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" onclick={cancelDeleteAll} disabled={deletingAll}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-confirm"
|
||||
disabled={deletingAll || deleteAllConfirmText.trim().toUpperCase() !== 'DELETE'}
|
||||
onclick={confirmDeleteAll}
|
||||
>
|
||||
{deletingAll ? 'Deleting…' : 'Delete all entries'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
@@ -622,6 +745,193 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Danger zone ────────────────────────────────────────────── */
|
||||
|
||||
.danger-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
margin: 0 1.75rem 1.75rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border: 1px solid color-mix(in srgb, #e53e3e 30%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, #e53e3e 5%, transparent);
|
||||
}
|
||||
|
||||
.danger-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.danger-copy h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.danger-copy p {
|
||||
margin: 0;
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.danger-result {
|
||||
margin-top: 0.5rem !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.58rem 1.1rem;
|
||||
background: #b3261e;
|
||||
color: #fff;
|
||||
border: 1px solid #b3261e;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #95201a;
|
||||
}
|
||||
|
||||
/* ── Confirmation modal ─────────────────────────────────────── */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(28rem, 100%);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
border-radius: 0.8rem;
|
||||
background: #fdecee;
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-confirm-field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.modal-confirm-field span {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-confirm-field input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.06em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-confirm-field input:focus {
|
||||
outline: none;
|
||||
border-color: #b3261e;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, #b3261e 18%, transparent);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.modal-cancel,
|
||||
.modal-confirm {
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-cancel:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
background: #b3261e;
|
||||
border: 1px solid #b3261e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-confirm:hover:not(:disabled) {
|
||||
background: #95201a;
|
||||
}
|
||||
|
||||
.modal-confirm:disabled,
|
||||
.modal-cancel:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -632,5 +942,10 @@
|
||||
.import-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user