v1.4 - Login fixes, etc
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let activeMenuId = $state<number | null>(null);
|
||||
let activeMenuTrigger = $state<HTMLButtonElement | null>(null);
|
||||
let menuElement = $state<HTMLDivElement | null>(null);
|
||||
let menuStyle = $state('');
|
||||
|
||||
function currency(value: number | null | undefined, digits = 2) {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -17,6 +22,86 @@
|
||||
? data.mixes.reduce((sum, mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
|
||||
: 0
|
||||
);
|
||||
const activeMix = $derived(data.mixes.find((mix) => mix.id === activeMenuId) ?? null);
|
||||
|
||||
function closeMenu() {
|
||||
activeMenuId = null;
|
||||
activeMenuTrigger = null;
|
||||
menuStyle = '';
|
||||
}
|
||||
|
||||
function positionMenu() {
|
||||
if (!activeMenuTrigger || !menuElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = activeMenuTrigger.getBoundingClientRect();
|
||||
const menuRect = menuElement.getBoundingClientRect();
|
||||
const viewportPadding = 12;
|
||||
const menuGap = 8;
|
||||
|
||||
let top = triggerRect.top - menuRect.height - menuGap;
|
||||
|
||||
if (top < viewportPadding) {
|
||||
top = Math.min(window.innerHeight - viewportPadding - menuRect.height, triggerRect.bottom + menuGap);
|
||||
}
|
||||
|
||||
let left = triggerRect.right - menuRect.width;
|
||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - viewportPadding - menuRect.width));
|
||||
|
||||
menuStyle = `top: ${Math.max(viewportPadding, top)}px; left: ${left}px;`;
|
||||
}
|
||||
|
||||
async function toggleMenu(id: number, event: MouseEvent) {
|
||||
if (activeMenuId === id) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
activeMenuId = id;
|
||||
activeMenuTrigger = event.currentTarget instanceof HTMLButtonElement ? event.currentTarget : null;
|
||||
|
||||
await tick();
|
||||
positionMenu();
|
||||
}
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (activeMenuId === null || !(event.target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (menuElement?.contains(event.target) || activeMenuTrigger?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (activeMenuId !== null) {
|
||||
positionMenu();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
@@ -77,7 +162,7 @@
|
||||
<tbody>
|
||||
{#each data.mixes as mix}
|
||||
<tr>
|
||||
<td>
|
||||
<td data-label="Mix">
|
||||
<div class="table-item">
|
||||
<span class="row-badge">MX</span>
|
||||
<div>
|
||||
@@ -86,26 +171,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{mix.client_name}</td>
|
||||
<td>{mix.ingredients.length}</td>
|
||||
<td>{mix.total_mix_kg}</td>
|
||||
<td>{currency(mix.total_mix_cost)}</td>
|
||||
<td>{currency(mix.mix_cost_per_kg, 4)}</td>
|
||||
<td>
|
||||
<td data-label="Client">{mix.client_name}</td>
|
||||
<td data-label="Ingredients">{mix.ingredients.length}</td>
|
||||
<td data-label="Total Kg">{mix.total_mix_kg}</td>
|
||||
<td data-label="Total Cost">{currency(mix.total_mix_cost)}</td>
|
||||
<td data-label="Cost / Kg">{currency(mix.mix_cost_per_kg, 4)}</td>
|
||||
<td data-label="Status">
|
||||
<span class={`status-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span>
|
||||
</td>
|
||||
<td class="menu-cell">
|
||||
<td class="menu-cell" data-label="Actions">
|
||||
<div class="menu-wrap">
|
||||
<button class="menu-trigger" type="button" onclick={() => (activeMenuId = activeMenuId === mix.id ? null : mix.id)}>
|
||||
<button
|
||||
aria-expanded={activeMenuId === mix.id}
|
||||
aria-haspopup="menu"
|
||||
class="menu-trigger"
|
||||
type="button"
|
||||
onclick={(event) => toggleMenu(mix.id, event)}
|
||||
>
|
||||
Actions
|
||||
</button>
|
||||
|
||||
{#if activeMenuId === mix.id}
|
||||
<div class="menu-panel">
|
||||
<a href={`/mixes/${mix.id}`}>Edit worksheet</a>
|
||||
<a href={`/mixes/${mix.id}`}>Open live cost view</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -113,6 +197,13 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if activeMix}
|
||||
<div bind:this={menuElement} class="menu-panel" role="menu" style={menuStyle}>
|
||||
<a href={`/mixes/${activeMix.id}`} onclick={closeMenu}>Edit worksheet</a>
|
||||
<a href={`/mixes/${activeMix.id}`} onclick={closeMenu}>Open live cost view</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -235,6 +326,7 @@
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 48rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 0.54rem;
|
||||
}
|
||||
@@ -328,10 +420,14 @@
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.menu-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.76rem;
|
||||
padding: 0.6rem 0.74rem;
|
||||
@@ -342,11 +438,10 @@
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
min-width: 10rem;
|
||||
width: min(14rem, calc(100vw - 1.5rem));
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
padding: 0.32rem;
|
||||
@@ -377,5 +472,77 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
table,
|
||||
thead,
|
||||
tbody,
|
||||
tr,
|
||||
td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 0;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
padding: 0.3rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.78rem 0.8rem;
|
||||
white-space: normal;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
tbody td:first-child,
|
||||
tbody td:last-child {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
tbody td + td {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
tbody td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.menu-wrap,
|
||||
.menu-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu-trigger {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load() {
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
mixes: []
|
||||
@@ -10,7 +10,7 @@ export async function load() {
|
||||
|
||||
try {
|
||||
return {
|
||||
mixes: await api.mixes()
|
||||
mixes: await api.mixes(fetch)
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export async function load({ params }) {
|
||||
export async function load({ params, fetch }) {
|
||||
const mixId = Number(params.id);
|
||||
|
||||
if (!Number.isFinite(mixId)) {
|
||||
@@ -17,7 +17,7 @@ export async function load({ params }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [mix, rawMaterials] = await Promise.all([api.mix(mixId), api.rawMaterials()]);
|
||||
const [mix, rawMaterials] = await Promise.all([api.mix(mixId, fetch), api.rawMaterials(fetch)]);
|
||||
|
||||
return {
|
||||
mix,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load() {
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
rawMaterials: []
|
||||
@@ -10,7 +10,7 @@ export async function load() {
|
||||
|
||||
try {
|
||||
return {
|
||||
rawMaterials: await api.rawMaterials()
|
||||
rawMaterials: await api.rawMaterials(fetch)
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user