Mix calculator
This commit is contained in:
@@ -6,9 +6,12 @@
|
||||
let { children } = $props();
|
||||
|
||||
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
|
||||
const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print'));
|
||||
</script>
|
||||
|
||||
{#if isAdminRoute}
|
||||
{#if isPrintableRoute}
|
||||
{@render children()}
|
||||
{:else if isAdminRoute}
|
||||
<AdminShell>
|
||||
{@render children()}
|
||||
</AdminShell>
|
||||
|
||||
@@ -4,6 +4,9 @@ const apiMocks = vi.hoisted(() => ({
|
||||
rawMaterials: vi.fn(),
|
||||
mixes: vi.fn(),
|
||||
mix: vi.fn(),
|
||||
mixCalculatorOptions: vi.fn(),
|
||||
mixCalculatorSessions: vi.fn(),
|
||||
mixCalculatorSession: vi.fn(),
|
||||
products: vi.fn(),
|
||||
productCosts: vi.fn(),
|
||||
scenarios: vi.fn(),
|
||||
@@ -30,6 +33,10 @@ import { load as adminLoad } from './admin/+page';
|
||||
import { load as mixesLoad } from './mixes/+page';
|
||||
import { load as mixNewLoad } from './mixes/new/+page';
|
||||
import { load as mixDetailLoad } from './mixes/[id]/+page';
|
||||
import { load as mixCalculatorLoad } from './mix-calculator/+page';
|
||||
import { load as mixCalculatorNewLoad } from './mix-calculator/new/+page';
|
||||
import { load as mixCalculatorDetailLoad } from './mix-calculator/[id]/+page';
|
||||
import { load as mixCalculatorPrintLoad } from './mix-calculator/[id]/print/+page';
|
||||
import { load as productsLoad } from './products/+page';
|
||||
import { load as rawMaterialsLoad } from './raw-materials/+page';
|
||||
import { load as scenariosLoad } from './scenarios/+page';
|
||||
@@ -47,6 +54,9 @@ describe('route loaders use the SvelteKit fetch argument', () => {
|
||||
apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]);
|
||||
apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
|
||||
apiMocks.mix.mockResolvedValue({ id: 42 });
|
||||
apiMocks.mixCalculatorOptions.mockResolvedValue({ clients: ['Hunter Premium Produce'], products: [{ product_id: 1 }] });
|
||||
apiMocks.mixCalculatorSessions.mockResolvedValue([{ id: 11 }]);
|
||||
apiMocks.mixCalculatorSession.mockResolvedValue({ id: 12 });
|
||||
apiMocks.products.mockResolvedValue([{ id: 3 }]);
|
||||
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
|
||||
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
|
||||
@@ -108,6 +118,31 @@ describe('route loaders use the SvelteKit fetch argument', () => {
|
||||
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
|
||||
});
|
||||
|
||||
it('passes fetch through the mix calculator history loader', async () => {
|
||||
await mixCalculatorLoad({ fetch: fetcher } as never);
|
||||
|
||||
expect(apiMocks.mixCalculatorSessions).toHaveBeenCalledWith(fetcher);
|
||||
});
|
||||
|
||||
it('passes fetch through the new mix calculator loader', async () => {
|
||||
await mixCalculatorNewLoad({ fetch: fetcher } as never);
|
||||
|
||||
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
|
||||
});
|
||||
|
||||
it('passes fetch through the mix calculator detail loader', async () => {
|
||||
await mixCalculatorDetailLoad({ params: { id: '12' }, fetch: fetcher } as never);
|
||||
|
||||
expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher);
|
||||
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
|
||||
});
|
||||
|
||||
it('passes fetch through the mix calculator print loader', async () => {
|
||||
await mixCalculatorPrintLoad({ params: { id: '12' }, fetch: fetcher } as never);
|
||||
|
||||
expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher);
|
||||
});
|
||||
|
||||
it('passes fetch through the scenarios loader', async () => {
|
||||
await scenariosLoad({ fetch: fetcher } as never);
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import { clientSession, hasModuleAccess } from '$lib/session';
|
||||
import type { MixCalculatorSession } from '$lib/types';
|
||||
|
||||
let { data }: { data: { sessions: MixCalculatorSession[] } } = $props();
|
||||
|
||||
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('en-NZ', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatNumber(value: number, digits = 2) {
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<h2>Saved production sessions</h2>
|
||||
<p>Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.</p>
|
||||
</div>
|
||||
{#if canEdit}
|
||||
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="metric-row">
|
||||
<article class="metric-card">
|
||||
<span>Saved Sessions</span>
|
||||
<strong>{data.sessions.length}</strong>
|
||||
<p>Visible under your access scope</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Total Planned Kg</span>
|
||||
<strong>{formatNumber(data.sessions.reduce((sum, session) => sum + session.total_kg, 0), 2)}</strong>
|
||||
<p>Across the visible history</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Sessions With Warnings</span>
|
||||
<strong>{data.sessions.filter((session) => session.warnings.length).length}</strong>
|
||||
<p>Fractional bag outputs need review</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<div class="table-toolbar">
|
||||
<div>
|
||||
<h3>Session history</h3>
|
||||
<p>Operators see their own sessions. Superadmins and admins see the full client history.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.sessions.length}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session</th>
|
||||
<th>Client / Product</th>
|
||||
<th>Batch</th>
|
||||
<th>Bags</th>
|
||||
<th>Prepared by</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.sessions as session}
|
||||
<tr>
|
||||
<td data-label="Session">
|
||||
<strong>{session.session_number}</strong>
|
||||
<span>{session.mix_name}</span>
|
||||
</td>
|
||||
<td data-label="Client / Product">
|
||||
<strong>{session.product_name}</strong>
|
||||
<span>{session.client_name}</span>
|
||||
</td>
|
||||
<td data-label="Batch">{formatNumber(session.batch_size_kg, 2)}kg</td>
|
||||
<td data-label="Bags">
|
||||
{formatNumber(session.total_bags, 2)}
|
||||
{#if session.warnings.length}
|
||||
<span class="warning-pill">Warn</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td data-label="Prepared by">{session.prepared_by_name}</td>
|
||||
<td data-label="Updated">{formatDate(session.updated_at)}</td>
|
||||
<td data-label="Open">
|
||||
<div class="row-actions">
|
||||
<a href={`/mix-calculator/${session.id}`}>Open</a>
|
||||
<a href={`/mix-calculator/${session.id}/print`}>Print</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<strong>No saved sessions yet</strong>
|
||||
<span>{canEdit ? 'Run a new calculation and save it to start your session history.' : 'No sessions are visible under your access scope yet.'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7d8d84;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.metric-row,
|
||||
.table-card {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
max-width: 15ch;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-intro p:last-child,
|
||||
.metric-card p,
|
||||
.table-toolbar p,
|
||||
tbody span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.78rem 0.96rem;
|
||||
border-radius: 0.9rem;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.table-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.3rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 1.15rem 1.2rem;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
margin: 0.55rem 0 0.3rem;
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 54rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 0.75rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
background: var(--panel-soft);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
tbody td:first-child {
|
||||
border-left: 1px solid var(--line);
|
||||
border-radius: 1rem 0 0 1rem;
|
||||
}
|
||||
|
||||
tbody td:last-child {
|
||||
border-right: 1px solid var(--line);
|
||||
border-radius: 0 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.row-actions a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warning-pill {
|
||||
display: inline-flex;
|
||||
margin-left: 0.55rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #fff6e6;
|
||||
color: #8b5b1e;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.metric-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-intro,
|
||||
.table-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from '$lib/api';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
sessions: []
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
return {
|
||||
sessions: hasModuleAccess(session, 'mix_calculator') ? await api.mixCalculatorSessions(fetch) : []
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
sessions: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
{#if data.session}
|
||||
<MixCalculatorWorkspace initialSession={data.session} options={data.options} />
|
||||
{:else}
|
||||
<section class="locked-card">
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<h2>Session unavailable.</h2>
|
||||
<p>The requested mix calculator session could not be loaded with the current access scope.</p>
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7d8d84;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.locked-card h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
}
|
||||
|
||||
.locked-card p:last-of-type {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
margin-top: 1rem;
|
||||
padding: 0.78rem 0.92rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.88rem;
|
||||
background: #fff;
|
||||
color: #304038;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { api } from '$lib/api';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
session: null,
|
||||
options: { clients: [], products: [] }
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
const canView = hasModuleAccess(session, 'mix_calculator');
|
||||
const canEdit = hasModuleAccess(session, 'mix_calculator', 'edit');
|
||||
|
||||
if (!canView) {
|
||||
return {
|
||||
session: null,
|
||||
options: { clients: [], products: [] }
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [savedSession, options] = await Promise.all([
|
||||
api.mixCalculatorSession(Number(params.id), fetch),
|
||||
canEdit ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
|
||||
]);
|
||||
|
||||
return {
|
||||
session: savedSession,
|
||||
options
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
session: null,
|
||||
options: { clients: [], products: [] }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import MixCalculatorPrintSheet from '$lib/components/MixCalculatorPrintSheet.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
{#if data.session}
|
||||
<MixCalculatorPrintSheet session={data.session} />
|
||||
{:else}
|
||||
<section class="locked-card">
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<h2>Printable session unavailable.</h2>
|
||||
<p>The saved session could not be loaded for printing.</p>
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7d8d84;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.locked-card h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
}
|
||||
|
||||
.locked-card p:last-of-type {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
margin-top: 1rem;
|
||||
padding: 0.78rem 0.92rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.88rem;
|
||||
background: #fff;
|
||||
color: #304038;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { api } from '$lib/api';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
session: null
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
if (!hasModuleAccess(session, 'mix_calculator')) {
|
||||
return {
|
||||
session: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
session: await api.mixCalculatorSession(Number(params.id), fetch)
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
session: null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<MixCalculatorWorkspace options={data.options} />
|
||||
@@ -0,0 +1,24 @@
|
||||
import { api } from '$lib/api';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
options: { clients: [], products: [] }
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
return {
|
||||
options: hasModuleAccess(session, 'mix_calculator', 'edit')
|
||||
? await api.mixCalculatorOptions(fetch)
|
||||
: { clients: [], products: [] }
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
options: { clients: [], products: [] }
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user