From cf968e802b71925fac547f54d1756655d7ceeb1b Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 3 Jun 2026 00:17:12 +1200 Subject: [PATCH] v0.1.11 - Editor --- README.md | 10 +- backend/app/api/client_access.py | 6 +- backend/app/api/deps.py | 6 + backend/app/api/editor.py | 137 ++ backend/app/core/access.py | 10 +- backend/app/main.py | 2 + backend/app/schemas/editor.py | 38 + backend/app/seed_access.py | 32 + backend/tests/test_access.py | 39 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/api.ts | 22 + .../src/lib/components/ClientShell.svelte | 436 +++++- .../src/lib/navigation/client-navigation.ts | 57 +- frontend/src/lib/types.ts | 33 + frontend/src/lib/workspace-access.test.ts | 62 +- frontend/src/lib/workspace-access.ts | 17 +- frontend/src/routes/+page.svelte | 6 +- frontend/src/routes/editor/+page.svelte | 1163 +++++++++++++++++ frontend/src/routes/editor/+page.ts | 29 + frontend/src/routes/mixes/+page.svelte | 318 +---- .../src/routes/raw-materials/+page.svelte | 84 +- frontend/src/routes/scenarios/+page.svelte | 307 ++--- 23 files changed, 2165 insertions(+), 655 deletions(-) create mode 100644 backend/app/api/editor.py create mode 100644 backend/app/schemas/editor.py create mode 100644 frontend/src/routes/editor/+page.svelte create mode 100644 frontend/src/routes/editor/+page.ts diff --git a/README.md b/README.md index 76e47da..11808db 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,15 @@ If your server already has a host-level nginx handling domains and TLS, use `dep ./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 ``` - Useful flags: `-Branch ` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key. + To deploy with password-based SSH auth instead of a key: + + ```powershell + ./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Password 'your-password' + ``` + + Password auth requires `sshpass` on your local `PATH`. + + Useful flags: `-Branch ` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key, `-Password` for password-based SSH auth. If a release adds or changes database-backed workbook formula structures, deploy with `-Seed` so the server refreshes seeded reference/formula data after the backend starts. For the product-formula change, this is required so Postgres receives the new `product_ingredients` rows sourced from `input_data/1.xlsx`. diff --git a/backend/app/api/client_access.py b/backend/app/api/client_access.py index c1cea7c..3a57d18 100644 --- a/backend/app/api/client_access.py +++ b/backend/app/api/client_access.py @@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/client-access", tags=["client-access"]) def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]: clients = list_client_accounts(db) - if session.role == "admin": + if session.role in {"admin", "internal"}: return clients return [client for client in clients if client.id == session.client_account_id] @@ -61,12 +61,12 @@ def _read_client_account(db: Session, client_id: int, session: AuthSession) -> d def _actor_metadata(session: AuthSession) -> dict[str, str]: - if session.role == "admin": + if session.role in {"admin", "internal"}: return { "actor_type": "lean_admin", "actor_name": session.name, "actor_email": session.email, - "actor_role": "admin", + "actor_role": session.client_role or "admin", } return { "actor_type": "client_superadmin", diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 5b9a8b5..d813856 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -190,6 +190,12 @@ def require_client_access_manager_session( ) -> AuthSession: if session.role == "admin": return session + if session.role == "internal": + permissions = session.module_permissions or {} + if not has_access_level(permissions.get("client_access"), "manage"): + log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires Lean access") + return session if session.role != "client": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access") diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py new file mode 100644 index 0000000..0eb6d5c --- /dev/null +++ b/backend/app/api/editor.py @@ -0,0 +1,137 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, joinedload + +from app.api.deps import AuthSession, get_auth_session +from app.db.session import get_db +from app.models.mix import Mix +from app.models.product import Product +from app.schemas.editor import EditorMixUpdate, EditorProductRow, EditorProductUpdate +from app.services.client_access_service import has_access_level + +router = APIRouter(prefix="/api/editor", tags=["editor"]) + + +def _serialize_row(product: Product) -> dict: + return { + "id": product.id, + "tenant_id": product.tenant_id, + "client_name": product.client_name, + "item_id": product.item_id, + "name": product.name, + "mix_id": product.mix_id, + "mix_client_name": product.mix.client_name if product.mix else "", + "mix_name": product.mix.name if product.mix else "", + "sale_type": product.sale_type, + "unit_of_measure": product.unit_of_measure, + "visible": product.visible, + "product_notes": product.notes, + "mix_notes": product.mix.notes if product.mix else None, + } + + +def _require_editor_session( + session: AuthSession = Depends(get_auth_session), + db: Session = Depends(get_db), +) -> AuthSession: + if session.role == "internal": + permissions = session.module_permissions or {} + if not has_access_level(permissions.get("client_access"), "manage"): + raise HTTPException(status_code=403, detail="Lean access is required") + if not has_access_level(permissions.get("products"), "edit"): + raise HTTPException(status_code=403, detail="products edit access is required") + if not has_access_level(permissions.get("mix_master"), "edit"): + raise HTTPException(status_code=403, detail="mix_master edit access is required") + if not session.tenant_id: + raise HTTPException(status_code=403, detail="Internal user context is missing") + return session + + raise HTTPException(status_code=403, detail="Lean access is required") + + +@router.get("/products", response_model=list[EditorProductRow]) +def list_editor_products( + q: str | None = Query(default=None, max_length=255), + client_name: str | None = Query(default=None, max_length=255), + limit: int = Query(default=500, ge=1, le=1000), + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + statement = ( + select(Product) + .where(Product.tenant_id == session.tenant_id) + .options(joinedload(Product.mix)) + .join(Product.mix) + .order_by(Product.client_name, Product.name, Product.id) + .limit(limit) + ) + + if client_name: + statement = statement.where(Product.client_name == client_name) + + if q: + term = f"%{q.strip()}%" + statement = statement.where( + or_( + Product.client_name.ilike(term), + Product.name.ilike(term), + Product.item_id.ilike(term), + Product.unit_of_measure.ilike(term), + Mix.name.ilike(term), + ) + ) + + return [_serialize_row(product) for product in db.scalars(statement).all()] + + +@router.patch("/products/{product_id}", response_model=EditorProductRow) +def update_editor_product( + product_id: int, + payload: EditorProductUpdate, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + product = db.scalar( + select(Product) + .where(Product.id == product_id, Product.tenant_id == session.tenant_id) + .options(joinedload(Product.mix)) + ) + if product is None: + raise HTTPException(status_code=404, detail="Product not found") + + if payload.mix_id is not None: + mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(product, field, value) + + db.commit() + db.refresh(product) + return _serialize_row(product) + + +@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow]) +def update_editor_mix( + mix_id: int, + payload: EditorMixUpdate, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(mix, field, value) + + db.commit() + + products = db.scalars( + select(Product) + .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) + .options(joinedload(Product.mix)) + .order_by(Product.client_name, Product.name, Product.id) + ).all() + return [_serialize_row(product) for product in products] diff --git a/backend/app/core/access.py b/backend/app/core/access.py index 66c5c52..6d277f1 100644 --- a/backend/app/core/access.py +++ b/backend/app/core/access.py @@ -53,10 +53,12 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = { "edit_mixes": ("mix_master", "edit"), "view_throughput": ("operations_throughput", "view"), "edit_throughput": ("operations_throughput", "edit"), - # Admin-only permissions (view_users, manage_users, manage_permissions, - # view_settings, edit_settings) are intentionally excluded — they don't - # correspond to any of the legacy module keys and remain accessible only - # via the explicit `require_permission(...)` dependency. + "view_scenarios": ("scenarios", "view"), + "edit_scenarios": ("scenarios", "edit"), + "manage_client_access": ("client_access", "manage"), + # User/role/settings permissions are intentionally excluded — they don't + # correspond to legacy module keys and remain accessible only via the + # explicit `require_permission(...)` dependency. } _ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3} diff --git a/backend/app/main.py b/backend/app/main.py index 325e656..3bac65b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from app.api.access import router as access_router from app.api.auth import router as auth_router from app.api.client_access import router as client_access_router from app.api.dashboard import router as dashboard_router +from app.api.editor import router as editor_router from app.api.mix_calculator import router as mix_calculator_router from app.api.mixes import router as mixes_router from app.api.powerbi import router as powerbi_router @@ -194,6 +195,7 @@ app.include_router(auth_router) app.include_router(access_router) app.include_router(client_access_router) app.include_router(dashboard_router) +app.include_router(editor_router) app.include_router(raw_materials_router) app.include_router(mixes_router) app.include_router(mix_calculator_router) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py new file mode 100644 index 0000000..9fb9bd0 --- /dev/null +++ b/backend/app/schemas/editor.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class EditorProductRow(BaseModel): + id: int + tenant_id: str + client_name: str + item_id: str | None + name: str + mix_id: int + mix_client_name: str + mix_name: str + sale_type: str + unit_of_measure: str + visible: bool + product_notes: str | None + mix_notes: str | None + + +class EditorProductUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + client_name: str | None = Field(default=None, min_length=1, max_length=255) + name: str | None = Field(default=None, min_length=1, max_length=255) + item_id: str | None = Field(default=None, max_length=128) + mix_id: int | None = None + sale_type: str | None = Field(default=None, min_length=1, max_length=64) + unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64) + visible: bool | None = None + notes: str | None = Field(default=None, max_length=2000) + + +class EditorMixUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + client_name: str | None = Field(default=None, min_length=1, max_length=255) + name: str | None = Field(default=None, min_length=1, max_length=255) + notes: str | None = Field(default=None, max_length=2000) diff --git a/backend/app/seed_access.py b/backend/app/seed_access.py index 99426cf..fc63b2e 100644 --- a/backend/app/seed_access.py +++ b/backend/app/seed_access.py @@ -31,6 +31,9 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = ( ("edit_mixes", "Create and edit mix master recipes"), ("view_throughput", "View operations throughput"), ("edit_throughput", "Create and edit operations throughput entries"), + ("view_scenarios", "View scenario planning"), + ("edit_scenarios", "Create, run, approve, and reject scenarios"), + ("manage_client_access", "Manage client accounts, users, feature access, and exports"), ("view_users", "View internal users and roles"), ("manage_users", "Create, deactivate, and assign user roles"), ("manage_permissions", "Modify roles and role-permission assignments"), @@ -50,10 +53,14 @@ ROLE_DEFINITIONS: dict[str, dict] = { "view_raw_materials", "edit_raw_materials", "view_products", + "edit_products", "view_mixes", "edit_mixes", "view_throughput", "edit_throughput", + "view_scenarios", + "edit_scenarios", + "manage_client_access", "view_users", "manage_users", "manage_permissions", @@ -88,6 +95,31 @@ ROLE_DEFINITIONS: dict[str, dict] = { "edit_throughput", ], }, + "lean": { + "description": "Lean owner access with unrestricted view/edit access across every workspace module.", + "permissions": [ + "view_dashboard", + "view_mix_calculator", + "use_mix_calculator", + "save_mix_calculator_session", + "view_raw_materials", + "edit_raw_materials", + "view_products", + "edit_products", + "view_mixes", + "edit_mixes", + "view_throughput", + "edit_throughput", + "view_scenarios", + "edit_scenarios", + "manage_client_access", + "view_users", + "manage_users", + "manage_permissions", + "view_settings", + "edit_settings", + ], + }, } diff --git a/backend/tests/test_access.py b/backend/tests/test_access.py index 12fa3e6..825df34 100644 --- a/backend/tests/test_access.py +++ b/backend/tests/test_access.py @@ -11,6 +11,7 @@ from app.api.access import router as access_router from app.core.access import ( INTERNAL_USER_SUBJECT, get_user_permissions, + permissions_to_module_map, require_all_permissions, require_any_permission, require_permission, @@ -72,9 +73,17 @@ def test_admin_role_permissions_match_spec(): assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"]) assert "manage_users" in granted assert "manage_permissions" in granted - # Admin spec deliberately excludes edit_products / edit_mixes. - assert "edit_products" not in granted - assert "edit_mixes" not in granted + assert "edit_products" in granted + assert "edit_mixes" in granted + assert "view_scenarios" in granted + assert "edit_scenarios" in granted + assert "manage_client_access" in granted + + modules = permissions_to_module_map(granted) + assert modules["products"] == "edit" + assert modules["mix_master"] == "edit" + assert modules["scenarios"] == "edit" + assert modules["client_access"] == "manage" def test_operations_role_is_mix_calculator_and_throughput_only(): @@ -108,6 +117,30 @@ def test_full_access_role_can_edit_operational_data_but_not_users(): assert "manage_permissions" not in granted +def test_lean_role_has_unrestricted_workspace_permissions(): + db = _build_session() + seed_access(db) + + lean_role = db.query(Role).filter_by(name="lean").one() + user = User(email="lean@example.com", name="Lean User", role_id=lean_role.id, is_active=True) + db.add(user) + db.flush() + + granted = get_user_permissions(user) + assert granted == set(ROLE_DEFINITIONS["lean"]["permissions"]) + assert {key for key, _ in PERMISSION_DEFINITIONS} == granted + + modules = permissions_to_module_map(granted) + assert modules["dashboard"] == "view" + assert modules["raw_materials"] == "edit" + assert modules["mix_master"] == "edit" + assert modules["mix_calculator"] == "edit" + assert modules["products"] == "edit" + assert modules["operations_throughput"] == "edit" + assert modules["scenarios"] == "edit" + assert modules["client_access"] == "manage" + + def test_inactive_user_has_no_permissions(): db = _build_session() seed_access(db) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 013fc26..f50bc96 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.8", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.8", + "version": "0.1.11", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 4743465..1f501d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.9", + "version": "0.1.11", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a71e867..f1d1d16 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -19,6 +19,9 @@ import type { ClientUserModulePermission, ClientUserUpdateInput, LoginResponse, + EditorMixUpdateInput, + EditorProductRow, + EditorProductUpdateInput, MixCalculatorCreateInput, MixCalculatorOptions, MixCalculatorPreview, @@ -318,6 +321,25 @@ export const api = { body: JSON.stringify(payload) }, 'client'), products: (fetcher?: ApiFetch) => cachedFetchJson('/api/products', mockProducts, 'client', fetcher), + editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => { + const search = new URLSearchParams(); + if (params?.q) search.set('q', params.q); + if (params?.client_name) search.set('client_name', params.client_name); + if (params?.limit) search.set('limit', String(params.limit)); + const qs = search.toString(); + const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products'; + return cachedFetchJson(path, [], 'client', fetcher); + }, + updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) => + request(`/api/editor/products/${productId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) => + request(`/api/editor/mixes/${mixId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), productCosts: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), scenarios: (fetcher?: ApiFetch) => cachedFetchJson('/api/scenarios', mockScenarios, 'client', fetcher), diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index ec3fb85..537aa90 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -14,12 +14,10 @@ canCreateMixWorksheet as sessionCanCreateMixWorksheet, canOpenClientAccess as sessionCanOpenClientAccess, canOpenDashboard as sessionCanOpenDashboard, + canOpenEditor as sessionCanOpenEditor, canOpenMixCalculator as sessionCanOpenMixCalculator, canOpenMixMaster as sessionCanOpenMixMaster, - canOpenProducts as sessionCanOpenProducts, - canOpenRawMaterials as sessionCanOpenRawMaterials, canOpenReporting as sessionCanOpenReporting, - canOpenScenarios as sessionCanOpenScenarios, canOpenSettings as sessionCanOpenSettings, canOpenThroughput as sessionCanOpenThroughput, canUseWorkspaceSearch as sessionCanUseWorkspaceSearch, @@ -32,6 +30,7 @@ baseSearchItems, clientBreadcrumbs, dashboardItem, + editorItem, footerLinks, matchesRoute, mixCalculatorItem, @@ -47,11 +46,11 @@ import packageInfo from '../../../package.json'; import { Calculator, - DollarSign, Search, LogOut, Plus, - Menu + Menu, + Settings } from 'lucide-svelte'; let { children } = $props(); @@ -71,13 +70,11 @@ const appVersion = `v${packageInfo.version}`; const currentYear = new Date().getFullYear(); const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession)); - const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession)); const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession)); const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession)); const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession)); const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession)); - const canOpenProducts = $derived(sessionCanOpenProducts($clientSession)); - const canOpenScenarios = $derived(sessionCanOpenScenarios($clientSession)); + const canOpenEditor = $derived(sessionCanOpenEditor($clientSession)); const canOpenSettings = $derived(sessionCanOpenSettings($clientSession)); const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession)); const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession)); @@ -94,10 +91,7 @@ !$clientSession ? workingDocumentItems : workingDocumentItems.filter((item) => { - if (item.href === '/raw-materials') return canOpenRawMaterials; if (item.href === '/mixes') return canOpenMixMaster; - if (item.href === '/products') return canOpenProducts; - if (item.href === '/scenarios') return canOpenScenarios; return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey); }) ); @@ -105,6 +99,7 @@ const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession)); const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null); const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null); + const visibleEditorItem = $derived(canOpenEditor ? editorItem : null); const isOperationsUser = $derived($clientSession?.role_name === 'Operations'); const workspaceRole = $derived(getWorkspaceRole($clientSession)); const visibleFooterLinks = $derived([ @@ -126,14 +121,12 @@ const visibleBaseSearchItems = $derived( baseSearchItems.filter((item) => { if (item.href === '/') return canOpenDashboard; - if (item.href === '/raw-materials') return canOpenRawMaterials; if (item.href === '/mixes') return canOpenMixMaster; if (item.href === '/mixes/new') return canCreateMixWorksheet; if (item.href === '/mix-calculator') return canOpenMixCalculator; - if (item.href === '/products') return canOpenProducts; + if (item.href === '/editor') return canOpenEditor; if (item.href === '/reporting') return sessionCanOpenReporting($clientSession); if (item.href === '/settings') return canOpenSettings; - if (item.href === '/scenarios') return canOpenScenarios; return true; }) ); @@ -266,24 +259,17 @@ seededSearchKey = sessionKey; Promise.all([ - sessionCanOpenProducts(session) ? api.products() : Promise.resolve([]), sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]), featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session) ? api.mixCalculatorSessions() : Promise.resolve([]) ]) - .then(([products, mixes, sessions]) => { + .then(([mixes, sessions]) => { if (seededSearchKey !== sessionKey) { return; } seededSearchItems = [ - ...products.map((product) => ({ - href: '/products', - label: product.name, - description: `Product · ${product.client_name} · ${product.mix_name}`, - keywords: `product ${product.name} ${product.client_name} ${product.mix_name} ${product.unit_of_measure}` - })), ...mixes.map((mix) => ({ href: `/mixes/${mix.id}`, label: mix.name, @@ -394,6 +380,7 @@ primaryItems={[ ...(visibleDashboardItem ? [visibleDashboardItem] : []), ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), + ...(visibleEditorItem ? [visibleEditorItem] : []), ...(visibleThroughputItem ? [visibleThroughputItem] : []), ...(visibleReportingItem ? [visibleReportingItem] : []) ]} @@ -458,16 +445,13 @@ {#if canCreateMixSession} Create mix session {/if} - {#if canOpenProducts} - Review delivered pricing - {/if} {#if canUseWorkspaceSearch} {/if} {/if} - {#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch} + {#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch} {/if} - {#if canOpenProducts} - (navOpen = false)}> - - Review delivered pricing - - {/if} {#if canUseWorkspaceSearch}