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:
2026-06-17 21:55:04 +12:00
parent 7db95e2027
commit 3f8279af10
24 changed files with 3820 additions and 37 deletions
+318 -3
View File
@@ -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>