v1.3 - client and admin scaffolding

This commit is contained in:
2026-04-25 22:51:36 +12:00
parent bc211ffcc8
commit 8cf9bfb441
54 changed files with 8882 additions and 1248 deletions
+280
View File
@@ -0,0 +1,280 @@
# Design Review
## Purpose
This document reviews the current UI design in the app as it exists today and captures the visual direction we should keep building toward.
The current design is strongest on:
- The global application shell
- The dashboard page
- The products page
The current design is weakest on:
- Raw materials
- Mixes
- Scenarios
Those pages still use older styling patterns and do not yet fully match the newer dashboard direction.
## Current Direction
The product is moving toward a modern professional operations dashboard with these core traits:
- Full-screen app layout
- Clean white panels on a pale neutral background
- Arial/Inter/IBM Plex Sans style typography
- Soft green as the primary brand/action color
- Rounded cards with subtle borders and restrained shadows
- Dense but readable information layout
- Dashboard-first composition with summary cards, charts, tables, and status pills
## What Is Working
### 1. App Shell
The shared shell in `frontend/src/routes/+layout.svelte` is the clearest part of the design system.
What works:
- Left navigation is simple and recognizably enterprise/SaaS
- Header layout is clean and easy to scan
- Sidebar, topbar, and content regions are clearly separated
- Border, radius, and spacing values are fairly consistent
- The palette is restrained enough for a data-heavy product
### 2. Typography
The shift toward IBM Plex Sans is a good move.
What works:
- Narrower letterforms help the UI feel more operational and less marketing-oriented
- Headings and labels now feel more compact
- The typography better suits dense tables and metrics
What still needs attention:
- Some older route pages still visually read as if they belong to a previous design phase
- Font sizing and spacing are not yet fully normalized across all pages
### 3. Dashboard Layout
The homepage has the most complete design language in the app right now.
What works:
- Good hierarchy from greeting to metrics to analysis to tasks
- Cards feel purposeful rather than decorative
- The dashboard uses a strong top-level grid
- Status chips and compact controls support the business-app direction
- The table redesign is much more modern than the earlier plain table styling
### 4. Products Table
The products page is now much closer to a polished admin table.
What works:
- Separated rounded rows feel modern and easier to scan
- First-column hierarchy is clearer
- Status and sale-type pills add useful structure
- Supporting metadata is visually subordinate to primary values
## Main Design Problems
### 1. The Design System Is Only Partially Applied
This is the biggest issue in the app right now.
The newer design language appears in:
- `frontend/src/routes/+layout.svelte`
- `frontend/src/routes/+page.svelte`
- `frontend/src/routes/products/+page.svelte`
But older page styles remain in:
- `frontend/src/routes/raw-materials/+page.svelte`
- `frontend/src/routes/mixes/+page.svelte`
- `frontend/src/routes/scenarios/+page.svelte`
This creates a split experience where the app shell feels professional, but some inner pages still feel like prototype screens.
### 2. Raw Materials Page Is Visually Out of Sync
The raw materials page still uses an older look and older tokens.
Current problems:
- Beige/tinted card treatment does not match the newer white panel system
- Buttons and inputs do not match the dashboard and products page controls
- Layout density feels heavier and more form-builder-like than operations-dashboard-like
- Card interiors are functional but not refined
This page matters a lot because it is one of the product's core workflows. It should be one of the strongest designed pages in the app, not one of the weakest.
### 3. Mixes And Scenarios Still Look Like MVP Scaffolding
These pages currently read as placeholders.
Current problems:
- Styling is minimal and visually detached from the newer shell
- Cards use older colors and border treatments
- Information hierarchy is too shallow
- There is little sense of table/list/system structure
### 4. Design Tokens Are Not Yet Truly Centralized
There is a good base token set in the layout, but page-level styles still define their own patterns too freely.
Symptoms:
- Different panel tones across pages
- Different table treatments across routes
- Different button personalities
- Different spacing rhythms
The app is close to needing a lightweight shared component/token layer instead of route-by-route styling.
### 5. Font Loading Strategy Should Be Revisited Later
IBM Plex Sans is the right direction, but it is currently loaded from a CSS `@import` in the route layout.
That is acceptable for now in a prototype, but longer term we should move to a more deliberate font-loading approach for performance and reliability.
## Recommended Design Principles
These should guide future UI work:
### 1. Keep The Shell Quiet
The shell should stay minimal and stable:
- White or near-white surfaces
- Very subtle borders
- Low-contrast background
- Minimal icon noise
The content should carry the visual interest, not the shell.
### 2. Prefer Information Density Over Decoration
The best parts of the current design are useful because they improve scanability rather than adding flourish.
We should keep prioritizing:
- Strong alignment
- Label/value clarity
- Good card grouping
- Useful color coding
- Structured tables
### 3. Use One Table Language Everywhere
The products table is a stronger starting point than the original table styles.
We should standardize around:
- Rounded row groups
- Soft separated rows
- Compact uppercase headers
- Badge-style statuses
- Strong first-column identity
- Muted secondary metadata
### 4. Treat Forms Like High-Value Workflow Surfaces
Raw material editing is a core task. Forms should feel as polished as dashboards.
That means:
- Cleaner sectioning
- Better spacing between fields
- More modern inputs
- Clearer primary/secondary actions
- Better use of inline metadata and supporting summaries
## Route-By-Route Assessment
### Dashboard
Status: Strong
Notes:
- Good overall hierarchy
- Good panel composition
- Good table direction
- Strongest reference page for the rest of the app
### Products
Status: Good
Notes:
- Table treatment is modern and clear
- Metrics row works well
- Could become the standard for other data-heavy pages
### Raw Materials
Status: Needs redesign pass
Notes:
- Strong functionality
- Weak visual consistency relative to the new shell
- Needs updated cards, forms, and downstream panels
### Mixes
Status: Needs redesign pass
Notes:
- Structurally too simple
- Should evolve into a more analytical operations page
- Needs stronger use of summaries, tables, and status markers
### Scenarios
Status: Needs redesign pass
Notes:
- Currently feels like raw prototype output
- JSON presentation is technically useful but visually unfinished
- Needs a proper scenario list and comparison layout
## Suggested Next Steps
### Immediate
1. Bring `raw-materials/+page.svelte` into the same visual language as the dashboard and products page.
2. Redesign the mixes page using the newer card/table tokens.
3. Redesign the scenarios page so it no longer looks like a raw debug view.
### Short Term
1. Standardize shared panel, button, input, and table styles.
2. Create one reusable status pill pattern.
3. Normalize spacing, heading sizes, and section headers across all routes.
### Medium Term
1. Move from route-local styling toward shared UI primitives.
2. Create a small written design system for color, spacing, radius, shadows, typography, and tables.
3. Revisit font loading so IBM Plex Sans is handled more deliberately.
## Final Assessment
The design is moving in the right direction.
The current shell, dashboard, and products page establish a much more credible business application style than the earlier prototype visuals. The main issue is not the direction itself, but inconsistency. Right now the app looks like two design generations living side by side.
If we bring the raw materials, mixes, and scenarios pages into the same system, the product will feel much more cohesive and substantially more production-ready.
+48 -11
View File
@@ -1,7 +1,13 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_admin_session, require_client_session
from app.core.config import settings from app.core.config import settings
from app.core.security import issue_token
from app.db.session import get_db
from app.models.client_access import ClientAccount
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -11,18 +17,49 @@ class LoginRequest(BaseModel):
password: str password: str
class LoginResponse(BaseModel): class SessionResponse(BaseModel):
name: str name: str
email: str email: str
role: str role: str
tenant_id: str | None = None
token: str
@router.post("/login", response_model=LoginResponse) def _build_session_response(*, name: str, email: str, role: str, tenant_id: str | None = None) -> SessionResponse:
def login(payload: LoginRequest): token = issue_token({"name": name, "email": email, "role": role, "tenant_id": tenant_id})
if payload.email.strip().lower() != settings.operator_email.lower() or payload.password != settings.operator_password: return SessionResponse(name=name, email=email, role=role, tenant_id=tenant_id, token=token)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return {
"name": settings.operator_name, @router.post("/client/login", response_model=SessionResponse)
"email": settings.operator_email, def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
"role": "operator", if payload.email.strip().lower() != settings.client_email.lower() or payload.password != settings.client_password:
} raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
client_account = db.scalar(select(ClientAccount).where(ClientAccount.tenant_id == settings.client_tenant_id))
if client_account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured")
return _build_session_response(
name=settings.client_name,
email=settings.client_email,
role="client",
tenant_id=client_account.tenant_id,
)
@router.post("/admin/login", response_model=SessionResponse)
def admin_login(payload: LoginRequest):
if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin email or password")
return _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
@router.get("/client/session", response_model=SessionResponse)
def read_client_session(session: AuthSession = Depends(require_client_session)):
return _build_session_response(name=session.name, email=session.email, role=session.role, tenant_id=session.tenant_id)
@router.get("/admin/session", response_model=SessionResponse)
def read_admin_session(session: AuthSession = Depends(require_admin_session)):
return _build_session_response(name=session.name, email=session.email, role=session.role)
+84
View File
@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import require_admin_session
from app.db.session import get_db
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.schemas.client_access import ClientAccessRead, ClientFeatureUpdate, ClientUserCreate, ClientUserUpdate
from app.services.client_access_service import list_client_accounts, serialize_client_account
router = APIRouter(prefix="/api/client-access", tags=["client-access"])
def _get_client_or_404(db: Session, client_id: int) -> ClientAccount:
client = db.scalar(select(ClientAccount).where(ClientAccount.id == client_id))
if client is None:
raise HTTPException(status_code=404, detail="Client account not found")
return client
def _read_client_account(db: Session, client_id: int) -> dict:
client = next((item for item in list_client_accounts(db) if item.id == client_id), None)
if client is None:
raise HTTPException(status_code=404, detail="Client account not found")
return serialize_client_account(client)
@router.get("", response_model=list[ClientAccessRead])
def get_client_access(db: Session = Depends(get_db), _: object = Depends(require_admin_session)):
return [serialize_client_account(client) for client in list_client_accounts(db)]
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
def create_client_user(
payload: ClientUserCreate,
db: Session = Depends(get_db),
_: object = Depends(require_admin_session),
):
client = _get_client_or_404(db, payload.client_account_id)
user = ClientUser(tenant_id=client.tenant_id, **payload.model_dump())
db.add(user)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
return _read_client_account(db, payload.client_account_id)
@router.patch("/users/{user_id}", response_model=ClientAccessRead)
def update_client_user(user_id: int, payload: ClientUserUpdate, db: Session = Depends(get_db), _: object = Depends(require_admin_session)):
user = db.scalar(select(ClientUser).where(ClientUser.id == user_id))
if user is None:
raise HTTPException(status_code=404, detail="Client user not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(user, field, value)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
return _read_client_account(db, user.client_account_id)
@router.patch("/features/{feature_id}", response_model=ClientAccessRead)
def update_client_feature(
feature_id: int,
payload: ClientFeatureUpdate,
db: Session = Depends(get_db),
_: object = Depends(require_admin_session),
):
feature = db.scalar(select(ClientFeatureAccess).where(ClientFeatureAccess.id == feature_id))
if feature is None:
raise HTTPException(status_code=404, detail="Client feature not found")
feature.enabled = payload.enabled
db.commit()
return _read_client_account(db, feature.client_account_id)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.security import verify_token
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(frozen=True)
class AuthSession:
role: str
email: str
name: str
tenant_id: str | None = None
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
return AuthSession(
role=str(payload.get("role", "")),
email=str(payload.get("email", "")),
name=str(payload.get("name", "")),
tenant_id=payload.get("tenant_id"),
)
def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
if not session.tenant_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client tenant is missing")
return session
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
if session.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session
+52 -19
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_session
from app.db.session import get_db from app.db.session import get_db
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
@@ -12,14 +13,15 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
@router.get("", response_model=list[MixRead]) @router.get("", response_model=list[MixRead])
def list_mixes(db: Session = Depends(get_db)): def list_mixes(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).order_by(Mix.name)).all() mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes] return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def create_mix(payload: MixCreate, db: Session = Depends(get_db)): def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
mix = Mix( mix = Mix(
tenant_id=session.tenant_id,
client_name=payload.client_name, client_name=payload.client_name,
name=payload.name, name=payload.name,
status=payload.status, status=payload.status,
@@ -29,10 +31,16 @@ def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
db.add(mix) db.add(mix)
db.flush() db.flush()
for ingredient in payload.ingredients: for ingredient in payload.ingredients:
if db.scalar(select(RawMaterial).where(RawMaterial.id == ingredient.raw_material_id)) is None: if db.scalar(
select(RawMaterial).where(
RawMaterial.id == ingredient.raw_material_id,
RawMaterial.tenant_id == session.tenant_id,
)
) is None:
raise HTTPException(status_code=404, detail=f"Raw material {ingredient.raw_material_id} not found") raise HTTPException(status_code=404, detail=f"Raw material {ingredient.raw_material_id} not found")
db.add( db.add(
MixIngredient( MixIngredient(
tenant_id=session.tenant_id,
mix_id=mix.id, mix_id=mix.id,
raw_material_id=ingredient.raw_material_id, raw_material_id=ingredient.raw_material_id,
quantity_kg=ingredient.quantity_kg, quantity_kg=ingredient.quantity_kg,
@@ -44,15 +52,15 @@ def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
@router.get("/{mix_id}", response_model=MixRead) @router.get("/{mix_id}", response_model=MixRead)
def get_mix(mix_id: int, db: Session = Depends(get_db)): def get_mix(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id) return calculate_mix_cost(db, mix_id)
@router.patch("/{mix_id}", response_model=MixRead) @router.patch("/{mix_id}", response_model=MixRead)
def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)): def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
mix = db.scalar(select(Mix).where(Mix.id == mix_id)) mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None: if mix is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
@@ -62,19 +70,39 @@ def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)):
@router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED) @router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, db: Session = Depends(get_db)): def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id)) is None: if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found") raise HTTPException(status_code=404, detail="Raw material not found")
db.add(MixIngredient(mix_id=mix_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, notes=payload.notes)) db.add(
MixIngredient(
tenant_id=session.tenant_id,
mix_id=mix_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
notes=payload.notes,
)
)
db.commit() db.commit()
return calculate_mix_cost(db, mix_id) return calculate_mix_cost(db, mix_id)
@router.patch("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead) @router.patch("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredientUpdate, db: Session = Depends(get_db)): def update_mix_ingredient(
ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id)) mix_id: int,
ingredient_id: int,
payload: MixIngredientUpdate,
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient).where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
MixIngredient.tenant_id == session.tenant_id,
)
)
if ingredient is None: if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found") raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
@@ -84,8 +112,14 @@ def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredien
@router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead) @router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends(get_db)): def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id)) ingredient = db.scalar(
select(MixIngredient).where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
MixIngredient.tenant_id == session.tenant_id,
)
)
if ingredient is None: if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found") raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient) db.delete(ingredient)
@@ -94,8 +128,7 @@ def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends
@router.get("/{mix_id}/cost-breakdown", response_model=MixRead) @router.get("/{mix_id}/cost-breakdown", response_model=MixRead)
def get_mix_cost_breakdown(mix_id: int, db: Session = Depends(get_db)): def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id) return calculate_mix_cost(db, mix_id)
+19 -13
View File
@@ -2,37 +2,39 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_admin_session, require_client_session
from app.db.session import get_db from app.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
from app.models.scenario import Scenario from app.models.scenario import Scenario
from app.services.client_access_service import build_client_access_export, list_client_accounts
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, serialize_raw_material from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, serialize_raw_material
router = APIRouter(prefix="/api/powerbi", tags=["powerbi"]) router = APIRouter(prefix="/api/powerbi", tags=["powerbi"])
@router.get("/raw-material-costs") @router.get("/raw-material-costs")
def raw_material_costs(db: Session = Depends(get_db)): def raw_material_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
materials = db.scalars(select(RawMaterial).order_by(RawMaterial.name)).all() materials = db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id).order_by(RawMaterial.name)).all()
return [serialize_raw_material(material) for material in materials] return [serialize_raw_material(material) for material in materials]
@router.get("/mix-costs") @router.get("/mix-costs")
def mix_costs(db: Session = Depends(get_db)): def mix_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).order_by(Mix.name)).all() mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes] return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.get("/product-costs") @router.get("/product-costs")
def product_costs(db: Session = Depends(get_db)): def product_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
products = db.scalars(select(Product).order_by(Product.name)).all() products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
return [calculate_product_cost(db, product.id) for product in products] return [calculate_product_cost(db, product.id) for product in products]
@router.get("/scenario-results") @router.get("/scenario-results")
def scenario_results(db: Session = Depends(get_db)): def scenario_results(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
scenarios = db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all() scenarios = db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
return [ return [
{ {
"scenario_id": scenario.id, "scenario_id": scenario.id,
@@ -45,20 +47,24 @@ def scenario_results(db: Session = Depends(get_db)):
] ]
@router.get("/client-access")
def client_access_export(_: AuthSession = Depends(require_admin_session), db: Session = Depends(get_db)):
return build_client_access_export(list_client_accounts(db))
@router.get("/data-quality-issues") @router.get("/data-quality-issues")
def data_quality_issues(db: Session = Depends(get_db)): def data_quality_issues(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
issues: list[dict] = [] issues: list[dict] = []
for mix in db.scalars(select(Mix)).all(): for mix in db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id)).all():
result = calculate_mix_cost(db, mix.id) result = calculate_mix_cost(db, mix.id)
for warning in result["warnings"]: for warning in result["warnings"]:
issues.append({"entity_type": "mix", "entity_id": mix.id, "entity_name": mix.name, "warning": warning}) issues.append({"entity_type": "mix", "entity_id": mix.id, "entity_name": mix.name, "warning": warning})
for product in db.scalars(select(Product)).all(): for product in db.scalars(select(Product).where(Product.tenant_id == session.tenant_id)).all():
result = calculate_product_cost(db, product.id) result = calculate_product_cost(db, product.id)
for warning in result["warnings"]: for warning in result["warnings"]:
issues.append({"entity_type": "product", "entity_id": product.id, "entity_name": product.name, "warning": warning}) issues.append({"entity_type": "product", "entity_id": product.id, "entity_name": product.name, "warning": warning})
for material in db.scalars(select(RawMaterial)).all(): for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id)).all():
serialized = serialize_raw_material(material) serialized = serialize_raw_material(material)
if serialized["current_price"] is None: if serialized["current_price"] is None:
issues.append({"entity_type": "raw_material", "entity_id": material.id, "entity_name": material.name, "warning": "No active price"}) issues.append({"entity_type": "raw_material", "entity_id": material.id, "entity_name": material.name, "warning": "No active price"})
return issues return issues
+17 -13
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_session
from app.db.session import get_db from app.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix
from app.models.product import Product from app.models.product import Product
@@ -33,16 +34,16 @@ def _serialize_product(product: Product) -> dict:
@router.get("", response_model=list[ProductRead]) @router.get("", response_model=list[ProductRead])
def list_products(db: Session = Depends(get_db)): def list_products(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
products = db.scalars(select(Product).order_by(Product.name)).all() products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
return [_serialize_product(product) for product in products] return [_serialize_product(product) for product in products]
@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
def create_product(payload: ProductCreate, db: Session = Depends(get_db)): def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
product = Product(**payload.model_dump()) product = Product(tenant_id=session.tenant_id, **payload.model_dump())
db.add(product) db.add(product)
db.commit() db.commit()
db.refresh(product) db.refresh(product)
@@ -50,19 +51,19 @@ def create_product(payload: ProductCreate, db: Session = Depends(get_db)):
@router.get("/{product_id}", response_model=ProductRead) @router.get("/{product_id}", response_model=ProductRead)
def get_product(product_id: int, db: Session = Depends(get_db)): def get_product(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id)) product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id))
if product is None: if product is None:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
return _serialize_product(product) return _serialize_product(product)
@router.patch("/{product_id}", response_model=ProductRead) @router.patch("/{product_id}", response_model=ProductRead)
def update_product(product_id: int, payload: ProductUpdate, db: Session = Depends(get_db)): def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id)) product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id))
if product is None: if product is None:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
if payload.mix_id is not None and db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None: if payload.mix_id is not None and db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value) setattr(product, field, value)
@@ -72,7 +73,9 @@ def update_product(product_id: int, payload: ProductUpdate, db: Session = Depend
@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown) @router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown)
def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)): def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Product not found")
try: try:
return calculate_product_cost(db, product_id) return calculate_product_cost(db, product_id)
except ValueError as exc: except ValueError as exc:
@@ -80,9 +83,10 @@ def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)):
@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown) @router.get("/{product_id}/price-output", response_model=ProductCostBreakdown)
def get_product_price_output(product_id: int, db: Session = Depends(get_db)): def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Product not found")
try: try:
return calculate_product_cost(db, product_id) return calculate_product_cost(db, product_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
+38 -12
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_session
from app.db.session import get_db from app.db.session import get_db
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.schemas.raw_material import ( from app.schemas.raw_material import (
@@ -33,14 +34,20 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
@router.get("", response_model=list[RawMaterialRead]) @router.get("", response_model=list[RawMaterialRead])
def list_raw_materials(db: Session = Depends(get_db)): def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
materials = db.scalars(select(RawMaterial).options(selectinload(RawMaterial.price_versions)).order_by(RawMaterial.name)).all() materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
).all()
return [serialize_raw_material(material) for material in materials] return [serialize_raw_material(material) for material in materials]
@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED)
def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db)): def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
material = RawMaterial( material = RawMaterial(
tenant_id=session.tenant_id,
name=payload.name, name=payload.name,
supplier=payload.supplier, supplier=payload.supplier,
unit_of_measure=payload.unit_of_measure, unit_of_measure=payload.unit_of_measure,
@@ -50,6 +57,7 @@ def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db
) )
material.price_versions.append( material.price_versions.append(
RawMaterialPriceVersion( RawMaterialPriceVersion(
tenant_id=session.tenant_id,
market_value=payload.initial_price.market_value, market_value=payload.initial_price.market_value,
waste_percentage=payload.initial_price.waste_percentage, waste_percentage=payload.initial_price.waste_percentage,
effective_date=payload.initial_price.effective_date, effective_date=payload.initial_price.effective_date,
@@ -64,9 +72,11 @@ def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db
@router.get("/{raw_material_id}", response_model=RawMaterialRead) @router.get("/{raw_material_id}", response_model=RawMaterialRead)
def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)): def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
material = db.scalar( material = db.scalar(
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions)) select(RawMaterial)
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
) )
if material is None: if material is None:
raise HTTPException(status_code=404, detail="Raw material not found") raise HTTPException(status_code=404, detail="Raw material not found")
@@ -74,9 +84,16 @@ def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)):
@router.patch("/{raw_material_id}", response_model=RawMaterialRead) @router.patch("/{raw_material_id}", response_model=RawMaterialRead)
def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Session = Depends(get_db)): def update_raw_material(
raw_material_id: int,
payload: RawMaterialUpdate,
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
material = db.scalar( material = db.scalar(
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions)) select(RawMaterial)
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
) )
if material is None: if material is None:
raise HTTPException(status_code=404, detail="Raw material not found") raise HTTPException(status_code=404, detail="Raw material not found")
@@ -88,11 +105,17 @@ def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Se
@router.post("/{raw_material_id}/prices", response_model=RawMaterialPriceVersionRead, status_code=status.HTTP_201_CREATED) @router.post("/{raw_material_id}/prices", response_model=RawMaterialPriceVersionRead, status_code=status.HTTP_201_CREATED)
def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCreate, db: Session = Depends(get_db)): def add_price_version(
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id)) raw_material_id: int,
payload: RawMaterialPriceVersionCreate,
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
if material is None: if material is None:
raise HTTPException(status_code=404, detail="Raw material not found") raise HTTPException(status_code=404, detail="Raw material not found")
price = RawMaterialPriceVersion( price = RawMaterialPriceVersion(
tenant_id=session.tenant_id,
raw_material_id=raw_material_id, raw_material_id=raw_material_id,
market_value=payload.market_value, market_value=payload.market_value,
waste_percentage=payload.waste_percentage, waste_percentage=payload.waste_percentage,
@@ -107,13 +130,16 @@ def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCrea
@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead]) @router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
def get_price_history(raw_material_id: int, db: Session = Depends(get_db)): def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id)) material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
if material is None: if material is None:
raise HTTPException(status_code=404, detail="Raw material not found") raise HTTPException(status_code=404, detail="Raw material not found")
prices = db.scalars( prices = db.scalars(
select(RawMaterialPriceVersion) select(RawMaterialPriceVersion)
.where(RawMaterialPriceVersion.raw_material_id == raw_material_id) .where(
RawMaterialPriceVersion.raw_material_id == raw_material_id,
RawMaterialPriceVersion.tenant_id == session.tenant_id,
)
.order_by(RawMaterialPriceVersion.effective_date.desc()) .order_by(RawMaterialPriceVersion.effective_date.desc())
).all() ).all()
items = [] items = []
+18 -16
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_session
from app.db.session import get_db from app.db.session import get_db
from app.models.scenario import CostingResult, Scenario from app.models.scenario import CostingResult, Scenario
from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse
@@ -11,13 +12,13 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@router.get("", response_model=list[ScenarioRead]) @router.get("", response_model=list[ScenarioRead])
def list_scenarios(db: Session = Depends(get_db)): def list_scenarios(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
return db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all() return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)): def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
scenario = Scenario(name=payload.name, description=payload.description, overrides=payload.overrides) scenario = Scenario(tenant_id=session.tenant_id, name=payload.name, description=payload.description, overrides=payload.overrides)
db.add(scenario) db.add(scenario)
db.commit() db.commit()
db.refresh(scenario) db.refresh(scenario)
@@ -25,16 +26,16 @@ def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)):
@router.get("/{scenario_id}", response_model=ScenarioRead) @router.get("/{scenario_id}", response_model=ScenarioRead)
def get_scenario(scenario_id: int, db: Session = Depends(get_db)): def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
return scenario return scenario
@router.post("/{scenario_id}/run", response_model=ScenarioRunResponse) @router.post("/{scenario_id}/run", response_model=ScenarioRunResponse)
def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)): def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
results = run_scenario(db, scenario) results = run_scenario(db, scenario)
@@ -43,11 +44,13 @@ def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)):
@router.get("/{scenario_id}/results") @router.get("/{scenario_id}/results")
def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)): def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
results = db.scalars(select(CostingResult).where(CostingResult.scenario_id == scenario_id)).all() results = db.scalars(
select(CostingResult).where(CostingResult.scenario_id == scenario_id, CostingResult.tenant_id == session.tenant_id)
).all()
return [ return [
{ {
"product_id": result.product_id, "product_id": result.product_id,
@@ -62,8 +65,8 @@ def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)):
@router.post("/{scenario_id}/approve", response_model=ScenarioRead) @router.post("/{scenario_id}/approve", response_model=ScenarioRead)
def approve_scenario(scenario_id: int, db: Session = Depends(get_db)): def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
scenario.status = "approved" scenario.status = "approved"
@@ -73,12 +76,11 @@ def approve_scenario(scenario_id: int, db: Session = Depends(get_db)):
@router.post("/{scenario_id}/reject", response_model=ScenarioRead) @router.post("/{scenario_id}/reject", response_model=ScenarioRead)
def reject_scenario(scenario_id: int, db: Session = Depends(get_db)): def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
scenario.status = "rejected" scenario.status = "rejected"
db.commit() db.commit()
db.refresh(scenario) db.refresh(scenario)
return scenario return scenario
+16 -6
View File
@@ -6,18 +6,28 @@ from dataclasses import dataclass
class Settings: class Settings:
app_name: str app_name: str
database_url: str database_url: str
operator_name: str client_name: str
operator_email: str client_email: str
operator_password: str client_password: str
client_tenant_id: str
admin_name: str
admin_email: str
admin_password: str
auth_secret: str
@classmethod @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
return cls( return cls(
app_name=os.getenv("APP_NAME", "Data Entry App API"), app_name=os.getenv("APP_NAME", "Data Entry App API"),
database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"), database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"),
operator_name=os.getenv("OPERATOR_NAME", "Operations Manager"), client_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"),
operator_email=os.getenv("OPERATOR_EMAIL", "operator@example.com"), client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"),
operator_password=os.getenv("OPERATOR_PASSWORD", "changeme"), client_password=os.getenv("CLIENT_PASSWORD", "changeme"),
client_tenant_id=os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce"),
admin_name=os.getenv("ADMIN_NAME", "Lean 101"),
admin_email=os.getenv("ADMIN_EMAIL", "admin@lean101.local"),
admin_password=os.getenv("ADMIN_PASSWORD", "lean101-admin"),
auth_secret=os.getenv("AUTH_SECRET", "lean-101-local-dev-secret"),
) )
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from fastapi import HTTPException, status
from app.core.config import settings
def _encode(data: dict[str, Any]) -> str:
raw = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
def _decode(value: str) -> dict[str, Any]:
padding = "=" * (-len(value) % 4)
raw = base64.urlsafe_b64decode(f"{value}{padding}".encode("utf-8"))
return json.loads(raw.decode("utf-8"))
def _sign(value: str) -> str:
signature = hmac.new(settings.auth_secret.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature).decode("utf-8").rstrip("=")
def issue_token(payload: dict[str, Any], ttl_seconds: int = 60 * 60 * 12) -> str:
body = {**payload, "exp": int(time.time()) + ttl_seconds}
encoded = _encode(body)
return f"{encoded}.{_sign(encoded)}"
def verify_token(token: str) -> dict[str, Any]:
try:
body, signature = token.split(".", 1)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token") from exc
expected_signature = _sign(body)
if not hmac.compare_digest(signature, expected_signature):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
payload = _decode(body)
if int(payload.get("exp", 0)) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired")
return payload
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine
TENANT_TABLES = {
"client_users": None,
"client_feature_access": None,
"raw_materials": None,
"raw_material_price_versions": None,
"mixes": None,
"mix_ingredients": None,
"products": None,
"scenarios": None,
"costing_results": None,
"process_cost_rules": None,
"packaging_cost_rules": None,
"freight_cost_rules": None,
}
def _has_column(engine: Engine, table_name: str, column_name: str) -> bool:
inspector = inspect(engine)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(column["name"] == column_name for column in columns)
def _add_tenant_column(engine: Engine, table_name: str) -> None:
if _has_column(engine, table_name, "tenant_id"):
return
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN tenant_id VARCHAR(64)"))
def _table_exists(engine: Engine, table_name: str) -> bool:
return inspect(engine).has_table(table_name)
def ensure_tenant_columns(engine: Engine) -> None:
for table_name in TENANT_TABLES:
if _table_exists(engine, table_name):
_add_tenant_column(engine, table_name)
def sync_tenant_ids(engine: Engine) -> None:
if not _table_exists(engine, "client_accounts"):
return
with engine.begin() as connection:
default_tenant = connection.execute(
text("SELECT tenant_id FROM client_accounts ORDER BY id LIMIT 1")
).scalar_one_or_none()
if not default_tenant:
return
statements = [
text(
"""
UPDATE client_users
SET tenant_id = (
SELECT client_accounts.tenant_id
FROM client_accounts
WHERE client_accounts.id = client_users.client_account_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE client_feature_access
SET tenant_id = (
SELECT client_accounts.tenant_id
FROM client_accounts
WHERE client_accounts.id = client_feature_access.client_account_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE raw_materials
SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE raw_material_price_versions
SET tenant_id = (
SELECT raw_materials.tenant_id
FROM raw_materials
WHERE raw_materials.id = raw_material_price_versions.raw_material_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE mixes
SET tenant_id = COALESCE(
(
SELECT client_accounts.tenant_id
FROM client_accounts
WHERE client_accounts.name = mixes.client_name
),
:default_tenant
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE mix_ingredients
SET tenant_id = (
SELECT mixes.tenant_id
FROM mixes
WHERE mixes.id = mix_ingredients.mix_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE products
SET tenant_id = COALESCE(
(
SELECT client_accounts.tenant_id
FROM client_accounts
WHERE client_accounts.name = products.client_name
),
(
SELECT mixes.tenant_id
FROM mixes
WHERE mixes.id = products.mix_id
),
:default_tenant
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE scenarios
SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE costing_results
SET tenant_id = COALESCE(
(
SELECT products.tenant_id
FROM products
WHERE products.id = costing_results.product_id
),
(
SELECT scenarios.tenant_id
FROM scenarios
WHERE scenarios.id = costing_results.scenario_id
),
:default_tenant
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE process_cost_rules
SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE packaging_cost_rules
SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
text(
"""
UPDATE freight_cost_rules
SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
]
for statement in statements:
connection.execute(statement, {"default_tenant": default_tenant})
+9 -2
View File
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router
from app.api.mixes import router as mixes_router from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router from app.api.powerbi import router as powerbi_router
from app.api.products import router as products_router from app.api.products import router as products_router
@@ -18,13 +19,16 @@ from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router from app.api.scenarios import router as scenarios_router
from app.core.config import settings from app.core.config import settings
from app.db.session import Base, engine from app.db.session import Base, engine
from app.db.migrations import ensure_tenant_columns, sync_tenant_ids
from app.seed import seed_if_empty from app.seed import seed_if_empty
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
ensure_tenant_columns(engine)
seed_if_empty() seed_if_empty()
sync_tenant_ids(engine)
yield yield
@@ -32,13 +36,14 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["http://localhost:5173", "http://localhost:5174"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(client_access_router)
app.include_router(raw_materials_router) app.include_router(raw_materials_router)
app.include_router(mixes_router) app.include_router(mixes_router)
app.include_router(products_router) app.include_router(products_router)
@@ -58,11 +63,13 @@ def root():
"Confirm finished product pricing outputs", "Confirm finished product pricing outputs",
], ],
"endpoints": { "endpoints": {
"login": "/api/auth/login", "client_login": "/api/auth/client/login",
"admin_login": "/api/auth/admin/login",
"raw_materials": "/api/raw-materials", "raw_materials": "/api/raw-materials",
"mixes": "/api/mixes", "mixes": "/api/mixes",
"products": "/api/products", "products": "/api/products",
"scenarios": "/api/scenarios", "scenarios": "/api/scenarios",
"client_access": "/api/client-access",
"docs": "/docs", "docs": "/docs",
}, },
} }
+4 -1
View File
@@ -1,10 +1,14 @@
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.scenario import CostingResult, Scenario from app.models.scenario import CostingResult, Scenario
__all__ = [ __all__ = [
"ClientAccount",
"ClientFeatureAccess",
"ClientUser",
"CostingResult", "CostingResult",
"FreightCostRule", "FreightCostRule",
"Mix", "Mix",
@@ -16,4 +20,3 @@ __all__ = [
"RawMaterialPriceVersion", "RawMaterialPriceVersion",
"Scenario", "Scenario",
] ]
+3
View File
@@ -10,6 +10,7 @@ class ProcessCostRule(Base):
__tablename__ = "process_cost_rules" __tablename__ = "process_cost_rules"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
process_name: Mapped[str] = mapped_column(String(64), unique=True) process_name: Mapped[str] = mapped_column(String(64), unique=True)
grading_cost: Mapped[float] = mapped_column(Float, default=0.0) grading_cost: Mapped[float] = mapped_column(Float, default=0.0)
bagging_cost: Mapped[float] = mapped_column(Float, default=0.0) bagging_cost: Mapped[float] = mapped_column(Float, default=0.0)
@@ -21,6 +22,7 @@ class PackagingCostRule(Base):
__tablename__ = "packaging_cost_rules" __tablename__ = "packaging_cost_rules"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
sale_type: Mapped[str] = mapped_column(String(64)) sale_type: Mapped[str] = mapped_column(String(64))
unit_of_measure: Mapped[str] = mapped_column(String(64)) unit_of_measure: Mapped[str] = mapped_column(String(64))
own_bag: Mapped[bool] = mapped_column(Boolean, default=False) own_bag: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -32,6 +34,7 @@ class FreightCostRule(Base):
__tablename__ = "freight_cost_rules" __tablename__ = "freight_cost_rules"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
sale_type: Mapped[str] = mapped_column(String(64)) sale_type: Mapped[str] = mapped_column(String(64))
unit_of_measure: Mapped[str] = mapped_column(String(64)) unit_of_measure: Mapped[str] = mapped_column(String(64))
cost_per_unit: Mapped[float] = mapped_column(Float, default=0.0) cost_per_unit: Mapped[float] = mapped_column(Float, default=0.0)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class ClientAccount(Base):
__tablename__ = "client_accounts"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
client_code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
status: Mapped[str] = mapped_column(String(32), default="active")
powerbi_workspace: Mapped[str | None] = mapped_column(String(128), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
users: Mapped[list["ClientUser"]] = relationship(
back_populates="client_account",
cascade="all, delete-orphan",
order_by="ClientUser.created_at.desc()",
)
features: Mapped[list["ClientFeatureAccess"]] = relationship(
back_populates="client_account",
cascade="all, delete-orphan",
order_by="ClientFeatureAccess.feature_group, ClientFeatureAccess.feature_name",
)
class ClientUser(Base):
__tablename__ = "client_users"
__table_args__ = (UniqueConstraint("client_account_id", "email", name="uq_client_user_email"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
full_name: Mapped[str] = mapped_column(String(255))
email: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(64), default="viewer")
status: Mapped[str] = mapped_column(String(32), default="invited")
is_new_user: Mapped[bool] = mapped_column(Boolean, default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="users")
class ClientFeatureAccess(Base):
__tablename__ = "client_feature_access"
__table_args__ = (UniqueConstraint("client_account_id", "feature_key", name="uq_client_feature"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
feature_key: Mapped[str] = mapped_column(String(64))
feature_name: Mapped[str] = mapped_column(String(255))
feature_group: Mapped[str] = mapped_column(String(64), default="operations")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="features")
+1 -1
View File
@@ -32,6 +32,7 @@ class MixIngredient(Base):
__table_args__ = (UniqueConstraint("mix_id", "raw_material_id", name="uq_mix_ingredient"),) __table_args__ = (UniqueConstraint("mix_id", "raw_material_id", name="uq_mix_ingredient"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True) mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True)
raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True) raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
quantity_kg: Mapped[float] = mapped_column(Float) quantity_kg: Mapped[float] = mapped_column(Float)
@@ -43,4 +44,3 @@ class MixIngredient(Base):
from app.models.product import Product # noqa: E402 from app.models.product import Product # noqa: E402
from app.models.raw_material import RawMaterial # noqa: E402 from app.models.raw_material import RawMaterial # noqa: E402
+1 -1
View File
@@ -32,6 +32,7 @@ class RawMaterialPriceVersion(Base):
__tablename__ = "raw_material_price_versions" __tablename__ = "raw_material_price_versions"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True) raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
market_value: Mapped[float] = mapped_column(Float) market_value: Mapped[float] = mapped_column(Float)
waste_percentage: Mapped[float] = mapped_column(Float, default=0.0) waste_percentage: Mapped[float] = mapped_column(Float, default=0.0)
@@ -41,4 +42,3 @@ class RawMaterialPriceVersion(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
raw_material: Mapped[RawMaterial] = relationship(back_populates="price_versions") raw_material: Mapped[RawMaterial] = relationship(back_populates="price_versions")
+2 -1
View File
@@ -12,6 +12,7 @@ class Scenario(Base):
__tablename__ = "scenarios" __tablename__ = "scenarios"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
name: Mapped[str] = mapped_column(String(255), unique=True) name: Mapped[str] = mapped_column(String(255), unique=True)
status: Mapped[str] = mapped_column(String(32), default="draft") status: Mapped[str] = mapped_column(String(32), default="draft")
description: Mapped[str | None] = mapped_column(String(500), nullable=True) description: Mapped[str | None] = mapped_column(String(500), nullable=True)
@@ -28,6 +29,7 @@ class CostingResult(Base):
__tablename__ = "costing_results" __tablename__ = "costing_results"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True) scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True) product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
finished_product_delivered: Mapped[float] = mapped_column(Float) finished_product_delivered: Mapped[float] = mapped_column(Float)
@@ -38,4 +40,3 @@ class CostingResult(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
scenario: Mapped[Scenario] = relationship(back_populates="results") scenario: Mapped[Scenario] = relationship(back_populates="results")
+65
View File
@@ -0,0 +1,65 @@
from datetime import datetime
from pydantic import BaseModel
class ClientUserCreate(BaseModel):
client_account_id: int
full_name: str
email: str
role: str = "viewer"
status: str = "invited"
is_new_user: bool = True
class ClientUserUpdate(BaseModel):
full_name: str | None = None
email: str | None = None
role: str | None = None
status: str | None = None
is_new_user: bool | None = None
class ClientFeatureUpdate(BaseModel):
enabled: bool
class ClientUserRead(BaseModel):
id: int
client_account_id: int
full_name: str
email: str
role: str
status: str
is_new_user: bool
last_login_at: datetime | None
created_at: datetime
class ClientFeatureRead(BaseModel):
id: int
client_account_id: int
feature_key: str
feature_name: str
feature_group: str
description: str | None
enabled: bool
updated_at: datetime
created_at: datetime
class ClientAccessRead(BaseModel):
id: int
tenant_id: str
name: str
client_code: str
status: str
powerbi_workspace: str | None
notes: str | None
created_at: datetime
users: list[ClientUserRead]
features: list[ClientFeatureRead]
active_user_count: int
new_user_count: int
enabled_feature_count: int
total_feature_count: int
+156 -58
View File
@@ -1,76 +1,174 @@
from datetime import date from datetime import date, datetime
from sqlalchemy import select from sqlalchemy import select
from app.db.session import Base, SessionLocal, engine from app.db.session import Base, SessionLocal, engine
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
CLIENT_FEATURES = [
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
]
def seed_client_access(db):
existing = db.scalar(select(ClientAccount.id))
if existing is not None:
return
specialty = ClientAccount(
tenant_id="hunter-premium-produce",
name="Hunter Premium Produce",
client_code="HPP",
status="active",
powerbi_workspace="hunter-premium-produce-prod",
notes="Primary production client for the Lean 101 admin and access workflows",
)
loft = ClientAccount(
tenant_id="loft-grains",
name="Loft Grains",
client_code="LOFT",
status="onboarding",
powerbi_workspace="farm-ops-sandbox",
notes="Onboarding workspace used to test staged user enablement",
)
db.add_all([specialty, loft])
db.flush()
specialty.users.extend(
[
ClientUser(
tenant_id=specialty.tenant_id,
full_name="Amelia Hart",
email="operator@example.com",
role="admin",
status="active",
is_new_user=False,
last_login_at=datetime(2026, 4, 24, 11, 30),
),
ClientUser(
tenant_id=specialty.tenant_id,
full_name="Ethan Cole",
email="ethan.cole@hunterpremiumproduce.example",
role="operator",
status="invited",
is_new_user=True,
),
]
)
loft.users.extend(
[
ClientUser(
tenant_id=loft.tenant_id,
full_name="Ruby Singh",
email="ruby.singh@loftgrains.example",
role="viewer",
status="active",
is_new_user=False,
last_login_at=datetime(2026, 4, 22, 9, 10),
)
]
)
enabled_feature_map = {
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export"},
"loft-grains": {"dashboard", "products", "powerbi_export"},
}
for client in (specialty, loft):
enabled_keys = enabled_feature_map[client.tenant_id]
for feature_key, feature_name, feature_group, description in CLIENT_FEATURES:
client.features.append(
ClientFeatureAccess(
tenant_id=client.tenant_id,
feature_key=feature_key,
feature_name=feature_name,
feature_group=feature_group,
description=description,
enabled=feature_key in enabled_keys,
)
)
def seed_costing_workspace(db):
existing = db.scalar(select(RawMaterial.id))
if existing is not None:
return
tenant_id = "hunter-premium-produce"
maize = RawMaterial(tenant_id=tenant_id, name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley = RawMaterial(tenant_id=tenant_id, name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
acid_buf = RawMaterial(tenant_id=tenant_id, name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active")
maize.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
barley.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
acid_buf.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=39, waste_percentage=0.0, effective_date=date(2026, 4, 1)))
db.add_all([maize, barley, acid_buf])
db.flush()
db.add_all(
[
ProcessCostRule(tenant_id=tenant_id, process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0),
ProcessCostRule(tenant_id=tenant_id, process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0),
PackagingCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63),
PackagingCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5),
FreightCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45),
FreightCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0),
]
)
db.flush()
mix = Mix(tenant_id=tenant_id, client_name="Hunter Premium Produce", name="Hunter Orchard Blend", status="active", version=1, notes="Seed recipe for MVP")
db.add(mix)
db.flush()
db.add_all(
[
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95),
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5),
]
)
db.flush()
db.add(
Product(
tenant_id=tenant_id,
client_name="Hunter Premium Produce",
item_id="SKU-001",
name="Hunter Orchard Blend 20kg",
mix_id=mix.id,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
bagging_process="standard_bagging",
distributor_margin=0.225,
wholesale_margin=0.18,
notes="Reference product for formula parity work",
)
)
def seed_if_empty(): def seed_if_empty():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
existing = db.scalar(select(RawMaterial.id)) seed_costing_workspace(db)
if existing is not None: seed_client_access(db)
return
maize = RawMaterial(name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley = RawMaterial(name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
acid_buf = RawMaterial(name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active")
maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
acid_buf.price_versions.append(RawMaterialPriceVersion(market_value=39, waste_percentage=0.0, effective_date=date(2026, 4, 1)))
db.add_all([maize, barley, acid_buf])
db.flush()
db.add_all(
[
ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0),
ProcessCostRule(process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0),
PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63),
PackagingCostRule(sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5),
FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45),
FreightCostRule(sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0),
]
)
db.flush()
mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1, notes="Seed recipe for MVP")
db.add(mix)
db.flush()
db.add_all(
[
MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95),
MixIngredient(mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5),
]
)
db.flush()
db.add(
Product(
client_name="Specialty Feeds",
item_id="SKU-001",
name="Specialty Pigeon Breeder 20kg",
mix_id=mix.id,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
bagging_process="standard_bagging",
distributor_margin=0.225,
wholesale_margin=0.18,
notes="Reference product for formula parity work",
)
)
db.commit() db.commit()
if __name__ == "__main__": if __name__ == "__main__":
seed_if_empty() seed_if_empty()
@@ -0,0 +1,134 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Select, select
from sqlalchemy.orm import Session, selectinload
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
def client_access_query() -> Select[tuple[ClientAccount]]:
return (
select(ClientAccount)
.options(selectinload(ClientAccount.users), selectinload(ClientAccount.features))
.order_by(ClientAccount.name)
)
def list_client_accounts(db: Session) -> list[ClientAccount]:
return db.scalars(client_access_query()).all()
def serialize_client_user(user: ClientUser) -> dict:
return {
"id": user.id,
"client_account_id": user.client_account_id,
"full_name": user.full_name,
"email": user.email,
"role": user.role,
"status": user.status,
"is_new_user": user.is_new_user,
"last_login_at": user.last_login_at,
"created_at": user.created_at,
}
def serialize_client_feature(feature: ClientFeatureAccess) -> dict:
return {
"id": feature.id,
"client_account_id": feature.client_account_id,
"feature_key": feature.feature_key,
"feature_name": feature.feature_name,
"feature_group": feature.feature_group,
"description": feature.description,
"enabled": feature.enabled,
"updated_at": feature.updated_at,
"created_at": feature.created_at,
}
def serialize_client_account(client: ClientAccount) -> dict:
users = [serialize_client_user(user) for user in client.users]
features = [serialize_client_feature(feature) for feature in client.features]
active_users = sum(1 for user in users if user["status"] == "active")
new_users = sum(1 for user in users if user["is_new_user"])
enabled_features = sum(1 for feature in features if feature["enabled"])
return {
"id": client.id,
"tenant_id": client.tenant_id,
"name": client.name,
"client_code": client.client_code,
"status": client.status,
"powerbi_workspace": client.powerbi_workspace,
"notes": client.notes,
"created_at": client.created_at,
"users": users,
"features": features,
"active_user_count": active_users,
"new_user_count": new_users,
"enabled_feature_count": enabled_features,
"total_feature_count": len(features),
}
def build_client_access_export(clients: list[ClientAccount]) -> dict:
serialized_clients = [serialize_client_account(client) for client in clients]
client_rows = []
user_rows = []
feature_rows = []
for client in serialized_clients:
client_rows.append(
{
"client_id": client["id"],
"tenant_id": client["tenant_id"],
"client_name": client["name"],
"client_code": client["client_code"],
"client_status": client["status"],
"powerbi_workspace": client["powerbi_workspace"],
"active_user_count": client["active_user_count"],
"new_user_count": client["new_user_count"],
"enabled_feature_count": client["enabled_feature_count"],
"total_feature_count": client["total_feature_count"],
}
)
for user in client["users"]:
user_rows.append(
{
"client_id": client["id"],
"client_name": client["name"],
"user_id": user["id"],
"full_name": user["full_name"],
"email": user["email"],
"role": user["role"],
"status": user["status"],
"is_new_user": user["is_new_user"],
"last_login_at": user["last_login_at"],
"created_at": user["created_at"],
}
)
for feature in client["features"]:
feature_rows.append(
{
"client_id": client["id"],
"client_name": client["name"],
"feature_id": feature["id"],
"feature_key": feature["feature_key"],
"feature_name": feature["feature_name"],
"feature_group": feature["feature_group"],
"enabled": feature["enabled"],
"updated_at": feature["updated_at"],
}
)
return {
"generated_at": datetime.utcnow(),
"client_rows": client_rows,
"user_rows": user_rows,
"feature_rows": feature_rows,
"clients": serialized_clients,
}
+20 -12
View File
@@ -123,7 +123,11 @@ def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -
if not process_name: if not process_name:
return 0.0, 0.0, 0.0, ["Missing bagging process"] return 0.0, 0.0, 0.0, ["Missing bagging process"]
rule = db.scalar(select(ProcessCostRule).where(ProcessCostRule.process_name == process_name)) tenant_id = overrides.get("tenant_id")
query = select(ProcessCostRule).where(ProcessCostRule.process_name == process_name)
if tenant_id:
query = query.where(ProcessCostRule.tenant_id == tenant_id)
rule = db.scalar(query)
if rule is None: if rule is None:
return 0.0, 0.0, 0.0, [f"Process rule not found for {process_name}"] return 0.0, 0.0, 0.0, [f"Process rule not found for {process_name}"]
@@ -138,13 +142,14 @@ def _get_packaging_cost(db: Session, product: Product, overrides: dict) -> tuple
if product.own_bag: if product.own_bag:
return 0.0, [] return 0.0, []
rule = db.scalar( query = select(PackagingCostRule).where(
select(PackagingCostRule).where( PackagingCostRule.sale_type == product.sale_type,
PackagingCostRule.sale_type == product.sale_type, PackagingCostRule.unit_of_measure == product.unit_of_measure,
PackagingCostRule.unit_of_measure == product.unit_of_measure, PackagingCostRule.own_bag == product.own_bag,
PackagingCostRule.own_bag == product.own_bag,
)
) )
if product.tenant_id:
query = query.where(PackagingCostRule.tenant_id == product.tenant_id)
rule = db.scalar(query)
if rule is None: if rule is None:
return 0.0, ["Packaging rule not found"] return 0.0, ["Packaging rule not found"]
@@ -152,12 +157,13 @@ def _get_packaging_cost(db: Session, product: Product, overrides: dict) -> tuple
def _get_freight_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]: def _get_freight_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]:
rule = db.scalar( query = select(FreightCostRule).where(
select(FreightCostRule).where( FreightCostRule.sale_type == product.sale_type,
FreightCostRule.sale_type == product.sale_type, FreightCostRule.unit_of_measure == product.unit_of_measure,
FreightCostRule.unit_of_measure == product.unit_of_measure,
)
) )
if product.tenant_id:
query = query.where(FreightCostRule.tenant_id == product.tenant_id)
rule = db.scalar(query)
if rule is None: if rule is None:
return 0.0, ["Freight rule not found"] return 0.0, ["Freight rule not found"]
return overrides.get("freight_costs", {}).get(str(rule.id), rule.cost_per_unit), [] return overrides.get("freight_costs", {}).get(str(rule.id), rule.cost_per_unit), []
@@ -185,9 +191,11 @@ def _extract_unit_quantity_kg(unit_of_measure: str) -> float:
def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict: def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
overrides = overrides or {} overrides = overrides or {}
overrides = {**overrides, "tenant_id": overrides.get("tenant_id")}
product = db.scalar(select(Product).where(Product.id == product_id).options(selectinload(Product.mix))) product = db.scalar(select(Product).where(Product.id == product_id).options(selectinload(Product.mix)))
if product is None: if product is None:
raise ValueError(f"Product {product_id} not found") raise ValueError(f"Product {product_id} not found")
overrides["tenant_id"] = product.tenant_id
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides) mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
warnings = list(mix_result["warnings"]) warnings = list(mix_result["warnings"])
+2 -2
View File
@@ -8,13 +8,14 @@ from app.services.costing_engine import calculate_product_cost
def run_scenario(db: Session, scenario: Scenario) -> list[dict]: def run_scenario(db: Session, scenario: Scenario) -> list[dict]:
db.execute(delete(CostingResult).where(CostingResult.scenario_id == scenario.id)) db.execute(delete(CostingResult).where(CostingResult.scenario_id == scenario.id))
products = db.scalars(select(Product).order_by(Product.name)).all() products = db.scalars(select(Product).where(Product.tenant_id == scenario.tenant_id).order_by(Product.name)).all()
results: list[dict] = [] results: list[dict] = []
for product in products: for product in products:
breakdown = calculate_product_cost(db, product.id, overrides=scenario.overrides or {}) breakdown = calculate_product_cost(db, product.id, overrides=scenario.overrides or {})
db.add( db.add(
CostingResult( CostingResult(
tenant_id=scenario.tenant_id,
scenario_id=scenario.id, scenario_id=scenario.id,
product_id=product.id, product_id=product.id,
finished_product_delivered=breakdown["finished_product_delivered"], finished_product_delivered=breakdown["finished_product_delivered"],
@@ -29,4 +30,3 @@ def run_scenario(db: Session, scenario: Scenario) -> list[dict]:
scenario.status = "reviewed" scenario.status = "reviewed"
db.commit() db.commit()
return results return results
+94 -6
View File
@@ -8,9 +8,11 @@ from app.core.config import settings
from app.db.session import Base from app.db.session import Base
from app.main import app from app.main import app
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.services.client_access_service import build_client_access_export, serialize_client_account
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
@@ -85,11 +87,97 @@ def test_root_and_login_endpoints():
root_response = client.get("/") root_response = client.get("/")
assert root_response.status_code == 200 assert root_response.status_code == 200
assert root_response.json()["endpoints"]["login"] == "/api/auth/login" assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login"
assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login"
login_response = client.post( client_login_response = client.post(
"/api/auth/login", "/api/auth/client/login",
json={"email": settings.operator_email, "password": settings.operator_password}, json={"email": settings.client_email, "password": settings.client_password},
) )
assert login_response.status_code == 200 assert client_login_response.status_code == 200
assert login_response.json()["email"] == settings.operator_email assert client_login_response.json()["email"] == settings.client_email
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
admin_login_response = client.post(
"/api/auth/admin/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
assert admin_login_response.status_code == 200
assert admin_login_response.json()["email"] == settings.admin_email
def test_client_access_export_helpers():
db = build_session()
client = ClientAccount(
tenant_id="specialty-feeds",
name="Specialty Feeds",
client_code="SPEC",
status="active",
powerbi_workspace="farm-ops-prod",
)
client.users.extend(
[
ClientUser(
full_name="Amelia Hart",
email="amelia.hart@specialtyfeeds.example",
role="admin",
status="active",
is_new_user=False,
),
ClientUser(
full_name="Ethan Cole",
email="ethan.cole@specialtyfeeds.example",
role="operator",
status="invited",
is_new_user=True,
),
]
)
client.features.extend(
[
ClientFeatureAccess(
feature_key="dashboard",
feature_name="Dashboard",
feature_group="workspace",
enabled=True,
),
ClientFeatureAccess(
feature_key="products",
feature_name="Products",
feature_group="pricing",
enabled=False,
),
]
)
db.add(client)
db.commit()
db.refresh(client)
serialized = serialize_client_account(client)
export = build_client_access_export([client])
assert serialized["active_user_count"] == 1
assert serialized["new_user_count"] == 1
assert serialized["enabled_feature_count"] == 1
assert export["client_rows"][0]["client_code"] == "SPEC"
assert export["user_rows"][0]["client_name"] == "Specialty Feeds"
assert len(export["feature_rows"]) == 2
def test_client_access_endpoints():
with TestClient(app) as client:
login_response = client.post(
"/api/auth/admin/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
token = login_response.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
access_response = client.get("/api/client-access", headers=headers)
assert access_response.status_code == 200
assert len(access_response.json()) >= 1
export_response = client.get("/api/powerbi/client-access", headers=headers)
assert export_response.status_code == 200
assert "client_rows" in export_response.json()
+114 -15
View File
@@ -1,7 +1,24 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { mockCosts, mockMixes, mockProducts, mockRawMaterials, mockScenarios } from '$lib/mock'; import { browser } from '$app/environment';
import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
mockMixes,
mockProducts,
mockRawMaterials,
mockScenarios
} from '$lib/mock';
import type { import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
ClientUserCreateInput,
ClientUserUpdateInput,
LoginResponse, LoginResponse,
Mix,
MixCreateInput,
MixIngredientUpdateInput,
MixUpdateInput,
Product, Product,
ProductCostBreakdown, ProductCostBreakdown,
RawMaterial, RawMaterial,
@@ -9,25 +26,55 @@ import type {
RawMaterialPriceCreateInput, RawMaterialPriceCreateInput,
Scenario Scenario
} from '$lib/types'; } from '$lib/types';
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000'; const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000';
async function fetchJson<T>(path: string, fallback: T): Promise<T> { type AuthMode = 'none' | 'client' | 'admin';
function getToken(auth: AuthMode) {
if (!browser) {
return null;
}
if (auth === 'client') {
return getStoredClientSession()?.token ?? null;
}
if (auth === 'admin') {
return getStoredAdminSession()?.token ?? null;
}
return null;
}
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none'): Promise<T> {
try { try {
const response = await fetch(`${API_BASE_URL}${path}`); const token = getToken(auth);
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
});
if (!response.ok) { if (!response.ok) {
if (auth !== 'none') {
throw new Error(response.statusText || 'Unauthorized');
}
return fallback; return fallback;
} }
return (await response.json()) as T; return (await response.json()) as T;
} catch { } catch (error) {
if (auth !== 'none') {
throw error;
}
return fallback; return fallback;
} }
} }
async function request<T>(path: string, options: RequestInit): Promise<T> { async function request<T>(path: string, options: RequestInit, auth: AuthMode = 'none'): Promise<T> {
const token = getToken(auth);
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetch(`${API_BASE_URL}${path}`, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}) ...(options.headers ?? {})
}, },
...options ...options
@@ -50,25 +97,77 @@ async function request<T>(path: string, options: RequestInit): Promise<T> {
} }
export const api = { export const api = {
rawMaterials: () => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials), rawMaterials: () => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client'),
mixes: () => fetchJson('/api/mixes', mockMixes), mixes: () => fetchJson('/api/mixes', mockMixes, 'client'),
products: () => fetchJson<Product[]>('/api/products', mockProducts), mix: (mixId: number) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client'),
productCosts: () => fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts), products: () => fetchJson<Product[]>('/api/products', mockProducts, 'client'),
scenarios: () => fetchJson<Scenario[]>('/api/scenarios', mockScenarios), productCosts: () => fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client'),
dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', []), scenarios: () => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client'),
login: (email: string, password: string) => clientAccess: () => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin'),
request<LoginResponse>('/api/auth/login', { clientAccessExport: () => fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin'),
dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', [], 'client'),
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}), }),
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
createMix: (payload: MixCreateInput) =>
request<Mix>('/api/mixes', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateMix: (mixId: number, payload: MixUpdateInput) =>
request<Mix>(`/api/mixes/${mixId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
addMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<Mix>(`/api/mixes/${mixId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteMixIngredient: (mixId: number, ingredientId: number) =>
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
createRawMaterial: (payload: RawMaterialCreateInput) => createRawMaterial: (payload: RawMaterialCreateInput) =>
request<RawMaterial>('/api/raw-materials', { request<RawMaterial>('/api/raw-materials', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}), }, 'client'),
addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) => addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) =>
request(`/api/raw-materials/${rawMaterialId}/prices`, { request(`/api/raw-materials/${rawMaterialId}/prices`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) }, 'client'),
createClientUser: (payload: ClientUserCreateInput) =>
request<ClientAccessAccount>('/api/client-access/users', {
method: 'POST',
body: JSON.stringify(payload)
}, 'admin'),
updateClientUser: (userId: number, payload: ClientUserUpdateInput) =>
request<ClientAccessAccount>(`/api/client-access/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'admin'),
updateClientFeature: (featureId: number, payload: { enabled: boolean }) =>
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'admin')
}; };
@@ -0,0 +1,333 @@
<script lang="ts">
import { page } from '$app/state';
import { adminSession } from '$lib/session';
const navigation = [
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
{ href: '/admin/client-access', label: 'Client Access', shortLabel: 'CA' }
];
let { children } = $props();
function matchesRoute(href: string, pathname: string) {
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
}
function pageTitle(pathname: string) {
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
}
function initials(name: string) {
return name
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Lean 101 Admin Panel</title>
</svelte:head>
<div class="admin-shell">
<aside class="admin-sidebar">
<a class="admin-brand" href="/admin">
<span class="brand-mark">L1</span>
<span>Lean 101 Admin Panel</span>
</a>
<p class="admin-copy">
Internal workspace for Lean 101 operators managing client access and controlled workspace changes.
</p>
<nav class="admin-nav" aria-label="Admin navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="admin-footer">
<a href="/">Open client workspace</a>
{#if $adminSession}
<button type="button" onclick={() => adminSession.clear()}>Sign out</button>
{/if}
</div>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div>
<p class="eyebrow">Admin Area</p>
<h1>{pageTitle(page.url.pathname)}</h1>
</div>
{#if $adminSession}
<div class="profile-card">
<span class="profile-avatar">{initials($adminSession.name)}</span>
<div>
<strong>{$adminSession.name}</strong>
<span>{$adminSession.email}</span>
</div>
</div>
{:else}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Admin sign-in required</strong>
<span>Use `/admin` to authenticate</span>
</div>
</div>
{/if}
</header>
<main class="admin-content">
{#if isProtectedRoute && !$adminSession}
<section class="locked-card">
<p class="eyebrow">Restricted</p>
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
<p>Client access controls are only available inside the separate admin workspace.</p>
<a href="/admin">Go to admin sign-in</a>
</section>
{:else}
{@render children()}
{/if}
</main>
</div>
</div>
<style>
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background:
radial-gradient(circle at top left, rgba(167, 217, 190, 0.22), transparent 34%),
linear-gradient(180deg, #f7f8f4 0%, #eef2ea 100%);
color: #203028;
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.1rem;
border-right: 1px solid rgba(34, 54, 45, 0.12);
background: rgba(20, 29, 24, 0.96);
color: #f4f7f1;
}
.admin-brand {
display: inline-flex;
align-items: center;
gap: 0.8rem;
font-size: 1.05rem;
font-weight: 700;
}
.brand-mark,
.nav-icon,
.profile-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand-mark {
width: 2rem;
height: 2rem;
border-radius: 0.72rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
}
.admin-copy {
margin: 0;
color: rgba(244, 247, 241, 0.74);
line-height: 1.55;
}
.admin-nav {
display: grid;
gap: 0.4rem;
}
.admin-nav a {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.78rem;
border-radius: 0.9rem;
color: rgba(244, 247, 241, 0.88);
transition: background-color 140ms ease;
}
.admin-nav a:hover,
.admin-nav a.active {
background: rgba(207, 228, 184, 0.16);
}
.admin-nav a.active {
color: #ffffff;
}
.nav-icon {
width: 1.65rem;
height: 1.65rem;
border-radius: 0.58rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
font-size: 0.7rem;
}
.admin-footer {
margin-top: auto;
display: grid;
gap: 0.6rem;
}
.admin-footer a,
.admin-footer button {
padding: 0.82rem 0.88rem;
border: 1px solid rgba(244, 247, 241, 0.14);
border-radius: 0.88rem;
background: rgba(255, 255, 255, 0.04);
color: inherit;
text-align: left;
cursor: pointer;
}
.admin-main {
min-width: 0;
display: flex;
flex-direction: column;
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem;
border-bottom: 1px solid rgba(34, 54, 45, 0.1);
background: rgba(247, 248, 244, 0.85);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 0.18rem;
color: #66806e;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-topbar h1 {
margin: 0;
font-size: 1.7rem;
}
.profile-card {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.45rem 0.52rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 0.95rem;
background: rgba(255, 255, 255, 0.82);
}
.profile-avatar {
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
color: #ffffff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
}
.profile-card strong,
.profile-card span {
display: block;
}
.profile-card span {
margin-top: 0.14rem;
color: #6b7f72;
font-size: 0.82rem;
}
.guest .profile-avatar {
background: linear-gradient(135deg, #c4d0c8 0%, #7b8b80 100%);
}
.admin-content {
min-width: 0;
padding: 1.4rem;
}
.locked-card {
max-width: 42rem;
padding: 1.35rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08);
}
.locked-card h2,
.locked-card p {
margin: 0;
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.8rem, 3vw, 2.3rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: #5d7166;
}
.locked-card a {
display: inline-flex;
margin-top: 1rem;
padding: 0.82rem 0.95rem;
border-radius: 0.9rem;
background: #203028;
color: #ffffff;
font-weight: 600;
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
border-right: none;
border-bottom: 1px solid rgba(34, 54, 45, 0.12);
}
}
@media (max-width: 720px) {
.admin-topbar {
flex-direction: column;
align-items: flex-start;
}
.admin-content {
padding: 1rem;
}
}
</style>
@@ -0,0 +1,857 @@
<script lang="ts">
import { api } from '$lib/api';
import type { ClientAccessAccount, ClientAccessFeature, ClientAccessPowerBiExport } from '$lib/types';
let { data } = $props();
let clients = $state<ClientAccessAccount[]>([]);
let exportPreview = $state<ClientAccessPowerBiExport>({
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
});
let selectedClientId = $state(0);
let fullName = $state('');
let email = $state('');
let role = $state('viewer');
let status = $state('invited');
let isNewUser = $state(true);
let formError = $state('');
let formSuccess = $state('');
let isSubmitting = $state(false);
let savingUserId = $state<number | null>(null);
let savingFeatureId = $state<number | null>(null);
let previewStatus = $state('Live preview loaded');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No activity yet';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
function initials(value: string) {
return value
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
function replaceClient(updatedClient: ClientAccessAccount) {
clients = clients.map((client) => (client.id === updatedClient.id ? updatedClient : client));
}
async function refreshExportPreview() {
exportPreview = await api.clientAccessExport();
previewStatus = `Preview refreshed ${formatDate(exportPreview.generated_at)}`;
}
async function handleCreateUser(event: SubmitEvent) {
event.preventDefault();
formError = '';
formSuccess = '';
if (!selectedClientId) {
formError = 'Select a client before creating a user.';
return;
}
if (!fullName.trim() || !email.trim()) {
formError = 'Name and email are required.';
return;
}
isSubmitting = true;
try {
const updatedClient = await api.createClientUser({
client_account_id: selectedClientId,
full_name: fullName.trim(),
email: email.trim(),
role,
status,
is_new_user: isNewUser
});
replaceClient(updatedClient);
await refreshExportPreview();
fullName = '';
email = '';
role = 'viewer';
status = 'invited';
isNewUser = true;
formSuccess = 'User created and included in the export preview.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to create client user';
} finally {
isSubmitting = false;
}
}
async function updateUser(userId: number, payload: { role?: string; status?: string; is_new_user?: boolean }) {
savingUserId = userId;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientUser(userId, payload);
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = 'User access updated.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update client user';
} finally {
savingUserId = null;
}
}
async function toggleFeature(feature: ClientAccessFeature) {
savingFeatureId = feature.id;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientFeature(feature.id, { enabled: !feature.enabled });
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = `${feature.feature_name} ${feature.enabled ? 'disabled' : 'enabled'}.`;
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update feature access';
} finally {
savingFeatureId = null;
}
}
$effect(() => {
if (!clients.length && data.clients.length) {
clients = structuredClone(data.clients) as ClientAccessAccount[];
}
if (!exportPreview.generated_at && data.exportPreview.generated_at) {
exportPreview = structuredClone(data.exportPreview) as ClientAccessPowerBiExport;
}
if (!selectedClientId && data.clients[0]) {
selectedClientId = data.clients[0].id;
}
});
const selectedClient = $derived(clients.find((client) => client.id === selectedClientId) ?? clients[0]);
const totalUsers = $derived(clients.reduce((sum, client) => sum + client.users.length, 0));
const totalEnabledFeatures = $derived(clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
const previewJson = $derived(JSON.stringify(exportPreview, null, 2));
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Client Access Control</p>
<h2>Manage client users, feature flags, and Power BI-ready access data from one admin workspace.</h2>
<p>The preview stays aligned with the export payload so access changes and reporting stay in sync.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Clients</span>
<strong>{clients.length}</strong>
<p>Accounts currently staged in the client app</p>
</article>
<article class="metric-card">
<span>Total Users</span>
<strong>{totalUsers}</strong>
<p>New and existing users across every client</p>
</article>
<article class="metric-card">
<span>Enabled Features</span>
<strong>{totalEnabledFeatures}</strong>
<p>Feature switches currently turned on</p>
</article>
</section>
<section class="workspace-grid">
<article class="surface-card client-list-card">
<div class="card-toolbar">
<div>
<h3>Clients</h3>
<p>Select a client before amending users or feature access.</p>
</div>
</div>
<div class="client-list">
{#each clients as client}
<button
class:selected={client.id === selectedClient?.id}
class="client-row"
type="button"
onclick={() => {
selectedClientId = client.id;
formError = '';
formSuccess = '';
}}
>
<div class="client-row-head">
<span class="client-badge">{client.client_code}</span>
<div>
<strong>{client.name}</strong>
<span>{client.tenant_id}</span>
</div>
</div>
<div class="client-row-meta">
<span class={`status-pill ${client.status === 'active' ? 'positive' : 'neutral'}`}>{client.status}</span>
<small>{client.active_user_count} active users</small>
</div>
</button>
{/each}
</div>
</article>
<article class="surface-card amend-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Selected Client</p>
<h3>{selectedClient?.name ?? 'No client selected'}</h3>
<p>{selectedClient?.powerbi_workspace ?? 'No Power BI workspace assigned yet.'}</p>
</div>
{#if selectedClient}
<span class={`status-pill ${selectedClient.status === 'active' ? 'positive' : 'neutral'}`}>{selectedClient.status}</span>
{/if}
</div>
<div class="client-summary">
<article>
<span>Existing users</span>
<strong>{selectedClient ? selectedClient.users.length - selectedClient.new_user_count : 0}</strong>
</article>
<article>
<span>New users</span>
<strong>{selectedClient?.new_user_count ?? 0}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{selectedClient?.enabled_feature_count ?? 0}/{selectedClient?.total_feature_count ?? 0}</strong>
</article>
</div>
<form class="create-user-form" onsubmit={handleCreateUser}>
<div class="section-title">
<h4>Add New User</h4>
<span>Creates the user and immediately updates the export preview.</span>
</div>
<div class="form-grid">
<label>
<span>Full name</span>
<input bind:value={fullName} placeholder="Jordan Lee" />
</label>
<label>
<span>Email</span>
<input bind:value={email} type="email" placeholder="jordan.lee@client.example" />
</label>
<label>
<span>Role</span>
<select bind:value={role}>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</label>
<label>
<span>Status</span>
<select bind:value={status}>
<option value="invited">Invited</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
</label>
</div>
<label class="toggle-row">
<div>
<strong>Mark as new user</strong>
<span>Controls the onboarding signal carried into the export.</span>
</div>
<input bind:checked={isNewUser} type="checkbox" />
</label>
<div class="form-actions">
<button class="primary-button" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving user...' : 'Create User'}
</button>
{#if formError}
<strong class="message error">{formError}</strong>
{/if}
{#if !formError && formSuccess}
<strong class="message success">{formSuccess}</strong>
{/if}
</div>
</form>
<div class="section-title">
<h4>Existing Users</h4>
<span>Roles, lifecycle state, and new-user status can be amended inline.</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>New User</th>
<th>Last Login</th>
</tr>
</thead>
<tbody>
{#each selectedClient?.users ?? [] as user}
<tr>
<td class="user-cell">
<div class="user-item">
<span class="user-badge">{initials(user.full_name)}</span>
<div>
<strong>{user.full_name}</strong>
<span>{user.email}</span>
</div>
</div>
</td>
<td>
<select
value={user.role}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { role: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</td>
<td>
<select
value={user.status}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { status: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="active">Active</option>
<option value="invited">Invited</option>
<option value="suspended">Suspended</option>
</select>
</td>
<td>
<label class="inline-toggle">
<input
checked={user.is_new_user}
disabled={savingUserId === user.id}
type="checkbox"
onchange={(event) =>
updateUser(user.id, { is_new_user: (event.currentTarget as HTMLInputElement).checked })}
/>
<span>{user.is_new_user ? 'New' : 'Existing'}</span>
</label>
</td>
<td>
<div class="date-block">
<strong>{user.status}</strong>
<span>{formatDate(user.last_login_at)}</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</article>
<article class="surface-card feature-card">
<div class="card-toolbar">
<div>
<h3>Feature Access</h3>
<p>Every client feature can be switched on or off independently.</p>
</div>
</div>
<div class="feature-list">
{#each selectedClient?.features ?? [] as feature}
<article class="feature-row">
<div>
<div class="feature-head">
<strong>{feature.feature_name}</strong>
<span>{feature.feature_group}</span>
</div>
<p>{feature.description}</p>
</div>
<button
class:enabled={feature.enabled}
class="feature-toggle"
type="button"
disabled={savingFeatureId === feature.id}
onclick={() => toggleFeature(feature)}
>
<span>{savingFeatureId === feature.id ? 'Saving...' : feature.enabled ? 'On' : 'Off'}</span>
</button>
</article>
{/each}
</div>
</article>
</section>
<section class="preview-grid">
<article class="surface-card preview-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Power BI Preview</p>
<h3>Export Shape</h3>
<p>{previewStatus}</p>
</div>
<span class="endpoint-pill">GET /api/powerbi/client-access</span>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{exportPreview.feature_rows.length}</strong>
</article>
</div>
<pre>{previewJson}</pre>
</article>
</section>
<style>
h2,
h3,
h4,
p,
pre {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.workspace-grid,
.preview-grid {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 20ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.card-toolbar p,
.client-row span,
.section-title span,
.feature-row p,
.feature-head span,
.date-block span,
.message,
pre {
color: var(--muted);
}
.metric-row,
.workspace-grid,
.preview-stats,
.client-summary,
.form-grid {
display: grid;
gap: 1rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.workspace-grid {
grid-template-columns: 0.78fr 1.5fr 1fr;
align-items: start;
}
.preview-stats,
.client-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card,
.surface-card {
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.06);
}
.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;
font-weight: 700;
}
.surface-card {
padding: 1.2rem;
}
.card-toolbar,
.client-row,
.client-row-head,
.client-row-meta,
.section-title,
.feature-row,
.form-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.card-toolbar,
.section-title {
margin-bottom: 1rem;
}
.card-toolbar h3,
.section-title h4 {
font-weight: 700;
}
.client-list,
.feature-list {
display: grid;
gap: 0.75rem;
}
.client-row {
width: 100%;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: rgba(248, 251, 249, 0.92);
text-align: left;
cursor: pointer;
}
.client-row.selected {
border-color: #b9dfc6;
background: var(--green-soft);
}
.client-row strong,
.user-item strong,
.feature-head strong,
.date-block strong {
display: block;
font-size: 0.96rem;
}
.client-badge,
.user-badge {
width: 2.45rem;
height: 2.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.8rem;
color: #fff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.client-summary {
margin-bottom: 1rem;
}
.client-summary article,
.preview-stats article {
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: rgba(248, 251, 249, 0.92);
}
.client-summary span,
.preview-stats span {
display: block;
margin-bottom: 0.28rem;
color: var(--muted);
font-size: 0.84rem;
}
.client-summary strong,
.preview-stats strong {
font-size: 1.3rem;
font-weight: 700;
}
.create-user-form {
margin-bottom: 1.2rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: rgba(248, 251, 249, 0.92);
}
label {
display: grid;
gap: 0.4rem;
}
label span,
.toggle-row span {
font-size: 0.84rem;
color: var(--muted);
}
input,
select {
width: 100%;
padding: 0.82rem 0.88rem;
border: 1px solid var(--line-strong);
border-radius: 0.82rem;
background: #fff;
color: var(--text);
}
.toggle-row,
.inline-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.toggle-row {
margin-top: 1rem;
}
.toggle-row input,
.inline-toggle input {
width: auto;
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
color: #fff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
box-shadow: 0 8px 20px rgba(32, 48, 40, 0.16);
font-weight: 600;
cursor: pointer;
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.message.error {
color: #b33636;
}
.message.success {
color: var(--green-deep);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
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: rgba(248, 251, 249, 0.92);
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;
}
.user-cell {
min-width: 19rem;
}
.user-item,
.feature-head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-pill,
.endpoint-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.feature-row {
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: rgba(248, 251, 249, 0.92);
}
.feature-toggle {
min-width: 4.6rem;
padding: 0.72rem 0.8rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: #fff;
color: #5a6c63;
font-weight: 700;
cursor: pointer;
}
.feature-toggle.enabled {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
}
.feature-toggle:disabled {
opacity: 0.7;
cursor: wait;
}
.endpoint-pill {
color: #245961;
background: var(--blue-soft);
}
pre {
margin-top: 1rem;
padding: 1rem;
border-radius: 1rem;
background: #18231d;
border: 1px solid #1f3028;
color: #d6e4dc;
overflow: auto;
font-size: 0.82rem;
line-height: 1.55;
max-height: 34rem;
}
@media (max-width: 1220px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 960px) {
.metric-row,
.preview-stats,
.client-summary,
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.card-toolbar,
.client-row,
.feature-row,
.form-actions,
.section-title {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,805 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession } from '$lib/session';
import { onMount, tick } from 'svelte';
type SearchItem = {
href: string;
label: string;
description: string;
keywords: string;
};
const navigation = [
{ href: '/', label: 'Overview', shortLabel: 'OV' },
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM' },
{ href: '/products', label: 'Products', shortLabel: 'PR' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC' }
];
const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
];
const searchItems: SearchItem[] = [
{
href: '/',
label: 'Open Hunter Overview',
description: 'Jump to the Hunter Premium Produce workspace summary.',
keywords: 'hunter premium produce overview dashboard workspace'
},
{
href: '/raw-materials',
label: 'Open Raw Materials',
description: 'Review live input costs that feed the pricing model.',
keywords: 'raw materials pricing inputs costs supplier'
},
{
href: '/mixes',
label: 'Open Mix Master',
description: 'Browse saved mixes and their costing outputs.',
keywords: 'mix master mixes recipes spreadsheet'
},
{
href: '/mixes/new',
label: 'Create New Mix',
description: 'Start a new costing worksheet for Hunter Premium Produce.',
keywords: 'new mix create worksheet hunter premium produce formula'
},
{
href: '/products',
label: 'Open Products',
description: 'Review delivered product pricing and margins.',
keywords: 'products pricing margins delivered outputs'
},
{
href: '/scenarios',
label: 'Open Scenarios',
description: 'Inspect planning scenarios and overrides.',
keywords: 'scenarios sandbox overrides compare planning'
}
];
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let paletteOpen = $state(false);
let paletteQuery = $state('');
let quickMenuOpen = $state(false);
let paletteInput: HTMLInputElement | null = $state(null);
function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href);
}
function pageTitle(pathname: string) {
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
}
function pageDescription(pathname: string) {
const descriptions: Record<string, string> = {
'/': 'Hunter Premium Produce client workspace',
'/raw-materials': 'Review source input costs and downstream exposure',
'/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/products': 'Track delivered product pricing and margin views',
'/scenarios': 'Compare alternate pricing and production assumptions'
};
return descriptions[pathname] ?? 'Hunter Premium Produce client workspace';
}
function openPalette(query = '') {
paletteQuery = query;
paletteOpen = true;
quickMenuOpen = false;
}
async function runSearchItem(item: SearchItem) {
paletteOpen = false;
paletteQuery = '';
await goto(item.href);
}
const filteredSearchItems = $derived(
searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
return haystack.includes(paletteQuery.trim().toLowerCase());
})
);
$effect(() => {
page.url.pathname;
quickMenuOpen = false;
paletteOpen = false;
paletteQuery = '';
});
$effect(() => {
if (paletteOpen) {
tick().then(() => paletteInput?.focus());
}
});
onMount(() => {
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/')) {
event.preventDefault();
openPalette();
}
if (event.key === 'Escape') {
paletteOpen = false;
quickMenuOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Hunter Premium Produce</title>
</svelte:head>
<div class="app-shell">
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">
<span class="brand-mark">HP</span>
<span>Hunter Premium Produce</span>
</a>
<button class="nav-toggle" type="button" aria-label="Navigation options">
<span></span>
</button>
</div>
<button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<span class="search-icon"></span>
<span class="search-placeholder">Search the workspace...</span>
<kbd>/</kbd>
</button>
<nav class="nav-list" aria-label="Client navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#each footerLinks as item}
<a href={item.href}>
<span class="nav-icon muted">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
</aside>
<div class="main-shell">
<header class="topbar">
<div class="topbar-copy">
<h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
</div>
<div class="topbar-actions">
{#if $clientSession}
<button class="workspace-chip session-chip" type="button" onclick={() => clientSession.clear()}>
<span class="workspace-label">Signed in</span>
<strong>{$clientSession.email}</strong>
</button>
{:else}
<div class="workspace-chip">
<span class="workspace-label">Client</span>
<strong>Sign in required</strong>
</div>
{/if}
<div class="menu-wrap">
<button class="action-button" type="button" onclick={() => (quickMenuOpen = !quickMenuOpen)}>
Quick Actions
<span class:open={quickMenuOpen} class="chevron"></span>
</button>
{#if quickMenuOpen}
<div class="menu-panel">
<a href="/mixes">Open mix costing</a>
<a href="/mixes/new">Create mix worksheet</a>
<a href="/products">Review delivered pricing</a>
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
</div>
{/if}
</div>
</div>
</header>
<main class="content">
{#if !isRootRoute && !$clientSession}
<section class="locked-card">
<p class="workspace-label">Client Sign-In Required</p>
<h2>Sign in on the Hunter Premium Produce home page to unlock workspace data.</h2>
<p>The client-facing routes stay empty until a valid client session is active.</p>
<a href="/">Return to sign-in</a>
</section>
{:else}
{@render children()}
{/if}
</main>
</div>
</div>
{#if paletteOpen}
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
<div
class="palette"
role="dialog"
aria-modal="true"
aria-label="Workspace search"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') {
paletteOpen = false;
}
}}
>
<div class="palette-input-row">
<span class="search-icon"></span>
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search pages, workflows, and pricing views..." />
<kbd>Esc</kbd>
</div>
<div class="palette-results">
{#if filteredSearchItems.length}
{#each filteredSearchItems as item}
<button class="palette-item" type="button" onclick={() => runSearchItem(item)}>
<div>
<strong>{item.label}</strong>
<span>{item.description}</span>
</div>
<small>{item.href}</small>
</button>
{/each}
{:else}
<div class="palette-empty">
<strong>No results</strong>
<span>Try searching for mixes, products, scenarios, or pricing.</span>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:global(:root) {
--bg: #f4f7f5;
--panel: #ffffff;
--panel-soft: #f8fbf9;
--line: #e5ece7;
--line-strong: #d9e4dd;
--text: #18231d;
--muted: #6d7d74;
--green: #22a95e;
--green-deep: #148249;
--green-soft: #eaf8ef;
--blue-soft: #eef7ff;
--shadow: 0 10px 30px rgba(15, 23, 17, 0.06);
}
:global(html, body) {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: Inter, "Segoe UI", sans-serif;
}
:global(*) {
box-sizing: border-box;
}
:global(h1, h2, h3, h4, h5, h6) {
font-family: Inter, "Segoe UI", sans-serif;
letter-spacing: -0.03em;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(a) {
color: inherit;
text-decoration: none;
}
.app-shell {
display: grid;
grid-template-columns: 228px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 0.9rem;
background: var(--panel);
border-right: 1px solid var(--line);
}
.brand-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.68rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.68rem;
font-size: 1.08rem;
font-weight: 700;
}
.brand-mark,
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand-mark {
width: 1.9rem;
height: 1.9rem;
border-radius: 0.68rem;
}
.nav-toggle,
.action-button,
.menu-panel button {
border: 1px solid var(--line);
background: var(--panel);
cursor: pointer;
}
.nav-toggle {
width: 2.05rem;
height: 2.05rem;
border-radius: 0.68rem;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.nav-toggle span,
.search-icon,
.chevron {
position: relative;
display: inline-block;
}
.nav-toggle span,
.nav-toggle span::before,
.nav-toggle span::after {
width: 0.88rem;
height: 2px;
background: currentColor;
border-radius: 999px;
content: '';
}
.nav-toggle span::before,
.nav-toggle span::after {
position: absolute;
left: 0;
}
.nav-toggle span::before {
top: -0.28rem;
}
.nav-toggle span::after {
top: 0.28rem;
}
.search-box {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.64rem;
width: 100%;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.82rem;
background: var(--panel-soft);
text-align: left;
cursor: pointer;
}
.search-placeholder {
color: #93a098;
}
.search-icon {
width: 0.82rem;
height: 0.82rem;
border: 2px solid #98a59d;
border-radius: 999px;
}
.search-icon::after {
content: '';
position: absolute;
right: -0.28rem;
bottom: -0.18rem;
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: #98a59d;
transform: rotate(45deg);
}
kbd {
padding: 0.1rem 0.42rem;
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: #fff;
font-size: 0.76rem;
}
.nav-list,
.sidebar-footer {
display: grid;
gap: 0.3rem;
}
.nav-list a,
.sidebar-footer a {
display: flex;
align-items: center;
gap: 0.68rem;
padding: 0.72rem 0.68rem;
border-radius: 0.82rem;
color: #304038;
transition: background-color 160ms ease;
}
.nav-list a:hover,
.sidebar-footer a:hover,
.nav-list a.active {
background: var(--green-soft);
}
.nav-list a.active {
color: var(--green-deep);
font-weight: 600;
}
.nav-icon {
width: 1.56rem;
height: 1.56rem;
border-radius: 0.56rem;
}
.nav-icon.muted {
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%);
}
.sidebar-footer {
margin-top: auto;
padding-top: 0.6rem;
}
.main-shell {
min-width: 0;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
padding: 0.86rem 1.34rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.topbar-copy h1,
.topbar-copy p {
margin: 0;
}
.topbar-copy h1 {
font-size: 1.62rem;
font-weight: 700;
}
.topbar-copy p {
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.92rem;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.68rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.workspace-chip {
display: grid;
gap: 0.14rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--line);
border-radius: 0.92rem;
background: var(--panel-soft);
}
.session-chip {
cursor: pointer;
text-align: left;
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.workspace-chip strong {
font-size: 0.96rem;
}
.menu-wrap {
position: relative;
}
.action-button {
display: inline-flex;
align-items: center;
gap: 0.62rem;
border-radius: 0.88rem;
padding: 0.68rem 0.84rem;
color: #304038;
}
.chevron {
width: 0.54rem;
height: 0.54rem;
border-right: 2px solid #7a8c82;
border-bottom: 2px solid #7a8c82;
transform: rotate(45deg);
transition: transform 140ms ease;
}
.chevron.open {
transform: rotate(-135deg);
}
.menu-panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 20;
min-width: 13rem;
display: grid;
gap: 0.18rem;
padding: 0.4rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
backdrop-filter: blur(10px);
}
.menu-panel a,
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: #304038;
text-align: left;
background: transparent;
border: none;
}
.menu-panel a:hover,
.menu-panel button:hover {
background: var(--panel-soft);
}
.content {
min-width: 0;
padding: 1.34rem;
}
.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,
.locked-card p {
margin: 0;
}
.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);
}
.locked-card a {
display: inline-flex;
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border-radius: 0.88rem;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
font-weight: 600;
}
.palette-overlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: start center;
padding: 8vh 1rem 1rem;
background: rgba(11, 18, 14, 0.3);
backdrop-filter: blur(10px);
}
.palette {
width: min(44rem, 100%);
border: 1px solid rgba(217, 228, 221, 0.9);
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 60px rgba(15, 23, 17, 0.16);
overflow: hidden;
}
.palette-input-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.8rem;
padding: 0.95rem 1rem;
border-bottom: 1px solid var(--line);
}
.palette-input-row input {
border: none;
outline: none;
background: transparent;
color: var(--text);
font-size: 0.98rem;
}
.palette-results {
max-height: 26rem;
overflow: auto;
padding: 0.5rem;
}
.palette-item,
.palette-empty {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.88rem 0.92rem;
border: none;
border-radius: 0.92rem;
text-align: left;
background: transparent;
}
.palette-item {
cursor: pointer;
}
.palette-item:hover {
background: var(--panel-soft);
}
.palette-item strong,
.palette-empty strong {
display: block;
font-size: 0.96rem;
}
.palette-item span,
.palette-empty span,
.palette-item small {
color: var(--muted);
}
.palette-item span {
display: block;
margin-top: 0.18rem;
font-size: 0.84rem;
}
.palette-item small {
flex-shrink: 0;
font-size: 0.76rem;
}
.palette-empty {
justify-content: flex-start;
}
@media (max-width: 1080px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--line);
}
.sidebar-footer {
margin-top: 0;
}
}
@media (max-width: 720px) {
.topbar,
.topbar-actions,
.action-button {
flex-direction: column;
align-items: flex-start;
}
.content {
padding: 0.92rem;
}
}
</style>
@@ -0,0 +1,884 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import type { Mix, MixIngredient, RawMaterial } from '$lib/types';
type DraftIngredient = {
id: number | null;
raw_material_id: number | null;
quantity_kg: number;
notes: string;
};
let {
rawMaterials,
initialMix = null
}: {
rawMaterials: RawMaterial[];
initialMix?: Mix | null;
} = $props();
const getInitialMix = () => initialMix;
let savedMix = $state<Mix | null>(getInitialMix());
let mixName = $state(getInitialMix()?.name ?? '');
let clientName = $state(getInitialMix()?.client_name ?? '');
let mixStatus = $state(getInitialMix()?.status ?? 'draft');
let mixVersion = $state(getInitialMix()?.version ?? 1);
let mixNotes = $state(getInitialMix()?.notes ?? '');
let draftIngredients = $state<DraftIngredient[]>([]);
let feedback = $state('');
let errorMessage = $state('');
let isSaving = $state(false);
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
function findRawMaterial(rawMaterialId: number | null) {
return rawMaterials.find((material) => material.id === rawMaterialId) ?? null;
}
function rowFromIngredient(ingredient: MixIngredient): DraftIngredient {
return {
id: ingredient.id,
raw_material_id: ingredient.raw_material_id,
quantity_kg: ingredient.quantity_kg,
notes: ingredient.notes ?? ''
};
}
function createEmptyIngredient(): DraftIngredient {
return {
id: null,
raw_material_id: rawMaterials[0]?.id ?? null,
quantity_kg: 0,
notes: ''
};
}
function loadDraftFromMix(mix: Mix | null) {
if (!mix) {
savedMix = null;
mixName = '';
clientName = '';
mixStatus = 'draft';
mixVersion = 1;
mixNotes = '';
draftIngredients = [createEmptyIngredient()];
return;
}
savedMix = mix;
mixName = mix.name;
clientName = mix.client_name;
mixStatus = mix.status;
mixVersion = mix.version ?? 1;
mixNotes = mix.notes ?? '';
draftIngredients = mix.ingredients.length ? mix.ingredients.map(rowFromIngredient) : [createEmptyIngredient()];
}
loadDraftFromMix(getInitialMix());
function resetDraft() {
feedback = '';
errorMessage = '';
loadDraftFromMix(savedMix);
}
function updateIngredientField(index: number, field: keyof DraftIngredient, value: string | number | null) {
draftIngredients = draftIngredients.map((row, rowIndex) =>
rowIndex === index
? {
...row,
[field]: value
}
: row
);
}
function addIngredientRow() {
draftIngredients = [...draftIngredients, createEmptyIngredient()];
}
function removeIngredientRow(index: number) {
draftIngredients = draftIngredients.filter((_, rowIndex) => rowIndex !== index);
if (!draftIngredients.length) {
draftIngredients = [createEmptyIngredient()];
}
}
function getCostPerKg(rawMaterialId: number | null) {
return findRawMaterial(rawMaterialId)?.current_price?.cost_per_kg ?? null;
}
function getDraftWarnings() {
const warnings: string[] = [];
const chosen = draftIngredients
.map((row) => row.raw_material_id)
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);
if (!mixName.trim()) {
warnings.push('Mix name is required.');
}
if (!clientName.trim()) {
warnings.push('Client name is required.');
}
if (!draftIngredients.length || draftIngredients.every((row) => !row.raw_material_id || row.quantity_kg <= 0)) {
warnings.push('Add at least one ingredient row with a positive quantity.');
}
if (new Set(chosen).size !== chosen.length) {
warnings.push('Each raw material can only appear once in a mix.');
}
draftIngredients.forEach((row, index) => {
if (row.raw_material_id === null) {
warnings.push(`Row ${index + 1} is missing a raw material.`);
}
if (row.quantity_kg <= 0) {
warnings.push(`Row ${index + 1} must have a quantity greater than zero.`);
}
if (row.raw_material_id !== null && getCostPerKg(row.raw_material_id) === null) {
warnings.push(`Row ${index + 1} has no active raw material price.`);
}
});
return warnings;
}
function getCleanIngredients() {
return draftIngredients
.filter((row) => row.raw_material_id !== null && row.quantity_kg > 0)
.map((row) => ({
id: row.id,
raw_material_id: row.raw_material_id as number,
quantity_kg: Number(row.quantity_kg),
notes: row.notes.trim() || null
}));
}
async function saveMix() {
feedback = '';
errorMessage = '';
const validationWarnings = getDraftWarnings();
if (validationWarnings.length) {
errorMessage = validationWarnings[0];
return;
}
isSaving = true;
try {
const cleanIngredients = getCleanIngredients();
if (!savedMix) {
const created = await api.createMix({
client_name: clientName.trim(),
name: mixName.trim(),
status: mixStatus,
version: mixVersion,
notes: mixNotes.trim() || null,
ingredients: cleanIngredients.map((row) => ({
raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg,
notes: row.notes
}))
});
await invalidateAll();
await goto(`/mixes/${created.id}`);
return;
}
await api.updateMix(savedMix.id, {
client_name: clientName.trim(),
name: mixName.trim(),
status: mixStatus,
version: mixVersion,
notes: mixNotes.trim() || null
});
const originalById = new Map(savedMix.ingredients.map((ingredient) => [ingredient.id, ingredient]));
const draftIds = new Set(cleanIngredients.filter((row) => row.id !== null).map((row) => row.id as number));
const rowsToDelete = savedMix.ingredients.filter((ingredient) => {
if (!draftIds.has(ingredient.id)) {
return true;
}
const draftRow = cleanIngredients.find((row) => row.id === ingredient.id);
return draftRow ? draftRow.raw_material_id !== ingredient.raw_material_id : false;
});
for (const ingredient of rowsToDelete) {
await api.deleteMixIngredient(savedMix.id, ingredient.id);
}
const rowsToAdd = cleanIngredients.filter((row) => {
if (row.id === null) {
return true;
}
const originalRow = originalById.get(row.id);
return originalRow ? originalRow.raw_material_id !== row.raw_material_id : true;
});
for (const row of rowsToAdd) {
await api.addMixIngredient(savedMix.id, {
raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg,
notes: row.notes
});
}
const rowsToPatch = cleanIngredients.filter((row) => {
if (row.id === null) {
return false;
}
const originalRow = originalById.get(row.id);
if (!originalRow || originalRow.raw_material_id !== row.raw_material_id) {
return false;
}
return originalRow.quantity_kg !== row.quantity_kg || (originalRow.notes ?? null) !== row.notes;
});
for (const row of rowsToPatch) {
await api.updateMixIngredient(savedMix.id, row.id as number, {
quantity_kg: row.quantity_kg,
notes: row.notes
});
}
const refreshed = await api.mix(savedMix.id);
await invalidateAll();
loadDraftFromMix(refreshed);
feedback = 'Mix saved with updated ingredient costing.';
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Unable to save mix';
} finally {
isSaving = false;
}
}
const draftRows = $derived(
draftIngredients.map((row) => {
const material = findRawMaterial(row.raw_material_id);
const costPerKg = material?.current_price?.cost_per_kg ?? null;
const lineCost = costPerKg === null ? null : Number((row.quantity_kg * costPerKg).toFixed(4));
return {
...row,
marketValue: material?.current_price?.market_value ?? null,
wastePercentage: material?.current_price?.waste_percentage ?? null,
costPerKg,
lineCost
};
})
);
const totalMixKg = $derived(draftRows.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0));
const totalMixCost = $derived(Number(draftRows.reduce((sum, row) => sum + Number(row.lineCost || 0), 0).toFixed(4)));
const mixCostPerKg = $derived(totalMixKg > 0 ? Number((totalMixCost / totalMixKg).toFixed(4)) : null);
const draftWarnings = $derived(getDraftWarnings());
</script>
{#if !$clientSession}
<section class="locked-card">
<p class="eyebrow">Client Access Required</p>
<h2>Sign in on the Hunter Premium Produce home page before editing mixes.</h2>
<p>Mix worksheets use live raw material pricing and save directly into Mix Master.</p>
<a href="/">Return to sign-in</a>
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">{savedMix ? 'Edit Mix' : 'New Mix'}</p>
<h2>{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}</h2>
<p>Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.</p>
</div>
<div class="intro-actions">
<a class="secondary-button" href="/mixes">Back to table</a>
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
</button>
</div>
</section>
{#if feedback}
<p class="feedback success">{feedback}</p>
{/if}
{#if errorMessage}
<p class="feedback error">{errorMessage}</p>
{/if}
<section class="metric-row">
<article class="metric-card">
<span>Live Draft Kg</span>
<strong>{totalMixKg.toFixed(2)}</strong>
<p>Total quantity in the current worksheet</p>
</article>
<article class="metric-card">
<span>Live Draft Cost</span>
<strong>{currency(totalMixCost)}</strong>
<p>Calculated from current row factors</p>
</article>
<article class="metric-card">
<span>Cost / Kg</span>
<strong>{currency(mixCostPerKg, 4)}</strong>
<p>Current worksheet output</p>
</article>
</section>
<section class="editor-grid">
<article class="editor-card">
<div class="section-heading">
<div>
<p class="eyebrow">Worksheet Meta</p>
<h3>Mix details</h3>
</div>
<div class="editor-actions">
<button class="secondary-button" type="button" onclick={resetDraft}>Reset</button>
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
</button>
</div>
</div>
<div class="meta-grid">
<label>
Mix Name
<input bind:value={mixName} placeholder="Hunter Orchard Blend" />
</label>
<label>
Client
<input bind:value={clientName} placeholder="Hunter Premium Produce" />
</label>
<label>
Status
<select bind:value={mixStatus}>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</label>
<label>
Version
<input bind:value={mixVersion} type="number" min="1" step="1" />
</label>
</div>
<label class="notes-field">
Notes
<textarea bind:value={mixNotes} rows="3" placeholder="Internal mixing notes, process assumptions, or version comments"></textarea>
</label>
<div class="section-heading spreadsheet-head">
<div>
<p class="eyebrow">Spreadsheet Rows</p>
<h4>Ingredient builder</h4>
</div>
<button class="secondary-button" type="button" onclick={addIngredientRow}>Add Row</button>
</div>
<div class="sheet-wrap">
<table class="sheet-table">
<thead>
<tr>
<th>Raw Material</th>
<th>Market Value</th>
<th>Waste %</th>
<th>Cost / Kg</th>
<th>Qty Kg</th>
<th>Line Cost</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{#each draftRows as row, index}
<tr>
<td>
<select
value={row.raw_material_id ?? ''}
onchange={(event) =>
updateIngredientField(
index,
'raw_material_id',
Number((event.currentTarget as HTMLSelectElement).value) || null
)}
>
<option value="">Select material</option>
{#each rawMaterials as material}
<option value={material.id}>{material.name}</option>
{/each}
</select>
</td>
<td>{currency(row.marketValue)}</td>
<td>{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
<td>{currency(row.costPerKg, 4)}</td>
<td>
<input
type="number"
min="0"
step="0.01"
value={row.quantity_kg}
oninput={(event) =>
updateIngredientField(index, 'quantity_kg', Number((event.currentTarget as HTMLInputElement).value))
}
/>
</td>
<td>{currency(row.lineCost)}</td>
<td>
<input
type="text"
value={row.notes}
oninput={(event) => updateIngredientField(index, 'notes', (event.currentTarget as HTMLInputElement).value)}
placeholder="Optional row note"
/>
</td>
<td>
<button class="icon-delete" type="button" onclick={() => removeIngredientRow(index)}>Remove</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</article>
<aside class="sidebar-stack">
<article class="summary-card">
<div class="section-heading">
<div>
<p class="eyebrow">Live Totals</p>
<h3>Worksheet summary</h3>
</div>
</div>
<div class="summary-grid">
<article>
<span>Total Kg</span>
<strong>{totalMixKg.toFixed(2)}</strong>
</article>
<article>
<span>Total Cost</span>
<strong>{currency(totalMixCost)}</strong>
</article>
<article>
<span>Cost / Kg</span>
<strong>{currency(mixCostPerKg, 4)}</strong>
</article>
<article>
<span>Rows</span>
<strong>{draftRows.length}</strong>
</article>
</div>
</article>
<article class="summary-card">
<div class="section-heading">
<div>
<p class="eyebrow">Calculation Factors</p>
<h3>What drives cost</h3>
</div>
</div>
<div class="factor-list">
<article>
<strong>Market value</strong>
<span>Each raw material row uses the active market value from the raw materials module.</span>
</article>
<article>
<strong>Waste percentage</strong>
<span>Material loss is included in the computed cost per kg used by the worksheet.</span>
</article>
<article>
<strong>Kg per unit</strong>
<span>Unit conversion already affects each raw material cost per kg before line costs are calculated.</span>
</article>
<article>
<strong>Quantity used</strong>
<span>Line cost is recalculated instantly from quantity times cost per kg.</span>
</article>
</div>
</article>
<article class="summary-card">
<div class="section-heading">
<div>
<p class="eyebrow">Draft Checks</p>
<h3>Validation</h3>
</div>
</div>
{#if draftWarnings.length}
<div class="warning-list">
{#each draftWarnings as warning}
<article>{warning}</article>
{/each}
</div>
{:else}
<div class="healthy-card">
<strong>Ready to save</strong>
<p>This draft has the required metadata and valid ingredient rows.</p>
</div>
{/if}
</article>
</aside>
</section>
{/if}
<style>
h2,
h3,
h4,
p {
margin: 0;
}
.eyebrow {
color: #7f8e85;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.locked-card,
.page-intro,
.feedback,
.metric-card,
.editor-card,
.summary-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.16rem;
box-shadow: var(--shadow);
}
.locked-card,
.page-intro,
.feedback,
.metric-row,
.editor-grid {
margin-bottom: 1.12rem;
}
.locked-card,
.page-intro,
.editor-card,
.summary-card {
padding: 1.08rem;
}
.locked-card {
display: grid;
gap: 0.62rem;
max-width: 40rem;
}
.locked-card h2,
.page-intro h2 {
margin: 0.3rem 0 0.4rem;
font-size: clamp(1.56rem, 3vw, 2.02rem);
font-weight: 700;
}
.locked-card p:last-of-type,
.page-intro p:last-child,
.metric-card p,
.summary-card span,
.factor-list span,
.healthy-card p {
color: var(--muted);
}
.locked-card a {
color: var(--green-deep);
font-weight: 600;
}
.page-intro,
.section-heading,
.intro-actions,
.editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.68rem;
}
.page-intro {
align-items: end;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.82rem;
padding: 0.74rem 0.9rem;
font-weight: 600;
cursor: pointer;
}
.primary-button {
border: none;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
}
.secondary-button {
border: 1px solid var(--line-strong);
color: #304038;
background: #fff;
}
.primary-button:disabled,
.secondary-button:disabled {
opacity: 0.7;
cursor: wait;
}
.feedback {
padding: 0.86rem 0.94rem;
font-weight: 600;
}
.feedback.success {
color: var(--green-deep);
border-color: #d8ecdf;
background: #f6fcf8;
}
.feedback.error {
color: #a03737;
border-color: #f0d9d9;
background: #fff8f8;
}
.metric-row,
.editor-grid,
.meta-grid,
.summary-grid {
display: grid;
gap: 0.9rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card {
padding: 1.04rem 1.08rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.48rem 0 0.26rem;
font-size: 1.72rem;
font-weight: 700;
}
.section-heading {
align-items: flex-start;
margin-bottom: 0.9rem;
}
.section-heading h3,
.section-heading h4 {
font-size: 1.02rem;
font-weight: 700;
}
.editor-grid {
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.75fr);
}
.editor-card,
.summary-card {
display: grid;
gap: 0.9rem;
}
.meta-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
label {
display: grid;
gap: 0.32rem;
color: #53645b;
font-size: 0.86rem;
font-weight: 600;
}
input,
textarea,
select {
width: 100%;
padding: 0.82rem 0.88rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: var(--panel-soft);
color: var(--text);
}
.sheet-wrap {
overflow-x: auto;
}
.sheet-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 0.54rem;
}
.sheet-table th,
.sheet-table td {
padding: 0.88rem 0.92rem;
text-align: left;
white-space: nowrap;
}
.sheet-table th {
color: var(--muted);
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sheet-table tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.sheet-table tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 0.92rem 0 0 0.92rem;
}
.sheet-table tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 0.92rem 0.92rem 0;
}
.sheet-table input,
.sheet-table select {
min-width: 8rem;
padding: 0.7rem 0.78rem;
background: #fff;
}
.icon-delete {
border: 1px solid #eed8d8;
border-radius: 0.74rem;
padding: 0.64rem 0.78rem;
color: #a34a4a;
background: #fff7f7;
font-weight: 600;
cursor: pointer;
}
.sidebar-stack,
.factor-list,
.warning-list {
display: grid;
gap: 0.9rem;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid article,
.factor-list article {
padding: 0.88rem 0.94rem;
border: 1px solid var(--line);
border-radius: 0.92rem;
background: var(--panel-soft);
}
.summary-grid strong {
display: block;
margin-top: 0.28rem;
font-size: 1.08rem;
font-weight: 700;
}
.factor-list strong,
.healthy-card strong {
display: block;
margin-bottom: 0.22rem;
font-size: 0.94rem;
font-weight: 700;
}
.warning-list article,
.healthy-card {
padding: 0.9rem 0.94rem;
border-radius: 0.92rem;
}
.warning-list article {
border: 1px solid #f1e2c2;
background: #fffaf2;
color: #8d5d21;
font-weight: 500;
}
.healthy-card {
border: 1px solid var(--line);
background: var(--panel-soft);
}
@media (max-width: 1180px) {
.editor-grid,
.metric-row,
.meta-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-intro,
.section-heading,
.intro-actions,
.editor-actions {
flex-direction: column;
align-items: flex-start;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>
+262 -7
View File
@@ -1,4 +1,12 @@
import type { Mix, Product, ProductCostBreakdown, RawMaterial, Scenario } from '$lib/types'; import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
Mix,
Product,
ProductCostBreakdown,
RawMaterial,
Scenario
} from '$lib/types';
export const mockRawMaterials: RawMaterial[] = [ export const mockRawMaterials: RawMaterial[] = [
{ {
@@ -32,8 +40,8 @@ export const mockRawMaterials: RawMaterial[] = [
export const mockMixes: Mix[] = [ export const mockMixes: Mix[] = [
{ {
id: 1, id: 1,
client_name: 'Specialty Feeds', client_name: 'Hunter Premium Produce',
name: 'Pigeon Mix', name: 'Hunter Orchard Blend',
status: 'active', status: 'active',
ingredients: [ ingredients: [
{ {
@@ -63,10 +71,10 @@ export const mockMixes: Mix[] = [
export const mockProducts: Product[] = [ export const mockProducts: Product[] = [
{ {
id: 1, id: 1,
name: 'Specialty Pigeon Breeder 20kg', name: 'Hunter Orchard Blend 20kg',
client_name: 'Specialty Feeds', client_name: 'Hunter Premium Produce',
mix_id: 1, mix_id: 1,
mix_name: 'Pigeon Mix', mix_name: 'Hunter Orchard Blend',
sale_type: 'standard', sale_type: 'standard',
unit_of_measure: '20kg bag', unit_of_measure: '20kg bag',
distributor_margin: 0.225, distributor_margin: 0.225,
@@ -77,7 +85,7 @@ export const mockProducts: Product[] = [
export const mockCosts: ProductCostBreakdown[] = [ export const mockCosts: ProductCostBreakdown[] = [
{ {
product_id: 1, product_id: 1,
product_name: 'Specialty Pigeon Breeder 20kg', product_name: 'Hunter Orchard Blend 20kg',
finished_product_delivered: 14.208, finished_product_delivered: 14.208,
distributor_price: 18.3329, distributor_price: 18.3329,
wholesale_price: 17.3268, wholesale_price: 17.3268,
@@ -94,3 +102,250 @@ export const mockScenarios: Scenario[] = [
overrides: {} overrides: {}
} }
]; ];
export const mockClientAccess: ClientAccessAccount[] = [
{
id: 1,
tenant_id: 'hunter-premium-produce',
name: 'Hunter Premium Produce',
client_code: 'HPP',
status: 'active',
powerbi_workspace: 'hunter-premium-produce-prod',
notes: 'Primary production client for the Lean 101 admin and access workflows',
created_at: '2026-04-20T09:00:00',
active_user_count: 1,
new_user_count: 1,
enabled_feature_count: 6,
total_feature_count: 6,
users: [
{
id: 1,
client_account_id: 1,
full_name: 'Amelia Hart',
email: 'operator@example.com',
role: 'admin',
status: 'active',
is_new_user: false,
last_login_at: '2026-04-24T11:30:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 2,
client_account_id: 1,
full_name: 'Ethan Cole',
email: 'ethan.cole@hunterpremiumproduce.example',
role: 'operator',
status: 'invited',
is_new_user: true,
last_login_at: null,
created_at: '2026-04-24T15:00:00'
}
],
features: [
{
id: 1,
client_account_id: 1,
feature_key: 'dashboard',
feature_name: 'Dashboard',
feature_group: 'workspace',
description: 'Top-level operational dashboard',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 2,
client_account_id: 1,
feature_key: 'raw_materials',
feature_name: 'Raw Materials',
feature_group: 'costing',
description: 'Maintain live material costs and versions',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 3,
client_account_id: 1,
feature_key: 'mix_master',
feature_name: 'Mix Master',
feature_group: 'costing',
description: 'Create and maintain mix worksheets',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 4,
client_account_id: 1,
feature_key: 'products',
feature_name: 'Products',
feature_group: 'pricing',
description: 'Review finished product pricing',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 5,
client_account_id: 1,
feature_key: 'scenarios',
feature_name: 'Scenarios',
feature_group: 'planning',
description: 'Run scenario overrides and comparisons',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 6,
client_account_id: 1,
feature_key: 'powerbi_export',
feature_name: 'Power BI Export',
feature_group: 'reporting',
description: 'Expose client access data to BI consumers',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
}
]
},
{
id: 2,
tenant_id: 'loft-grains',
name: 'Loft Grains',
client_code: 'LOFT',
status: 'onboarding',
powerbi_workspace: 'farm-ops-sandbox',
notes: 'Onboarding workspace used to test staged user enablement',
created_at: '2026-04-21T10:00:00',
active_user_count: 1,
new_user_count: 0,
enabled_feature_count: 3,
total_feature_count: 6,
users: [
{
id: 3,
client_account_id: 2,
full_name: 'Ruby Singh',
email: 'ruby.singh@loftgrains.example',
role: 'viewer',
status: 'active',
is_new_user: false,
last_login_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
}
],
features: [
{
id: 7,
client_account_id: 2,
feature_key: 'dashboard',
feature_name: 'Dashboard',
feature_group: 'workspace',
description: 'Top-level operational dashboard',
enabled: true,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
},
{
id: 8,
client_account_id: 2,
feature_key: 'raw_materials',
feature_name: 'Raw Materials',
feature_group: 'costing',
description: 'Maintain live material costs and versions',
enabled: false,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
},
{
id: 9,
client_account_id: 2,
feature_key: 'mix_master',
feature_name: 'Mix Master',
feature_group: 'costing',
description: 'Create and maintain mix worksheets',
enabled: false,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
},
{
id: 10,
client_account_id: 2,
feature_key: 'products',
feature_name: 'Products',
feature_group: 'pricing',
description: 'Review finished product pricing',
enabled: true,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
},
{
id: 11,
client_account_id: 2,
feature_key: 'scenarios',
feature_name: 'Scenarios',
feature_group: 'planning',
description: 'Run scenario overrides and comparisons',
enabled: false,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
},
{
id: 12,
client_account_id: 2,
feature_key: 'powerbi_export',
feature_name: 'Power BI Export',
feature_group: 'reporting',
description: 'Expose client access data to BI consumers',
enabled: true,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
}
]
}
];
export const mockClientAccessExport: ClientAccessPowerBiExport = {
generated_at: '2026-04-25T09:00:00',
client_rows: mockClientAccess.map((client) => ({
client_id: client.id,
tenant_id: client.tenant_id,
client_name: client.name,
client_code: client.client_code,
client_status: client.status,
powerbi_workspace: client.powerbi_workspace,
active_user_count: client.active_user_count,
new_user_count: client.new_user_count,
enabled_feature_count: client.enabled_feature_count,
total_feature_count: client.total_feature_count
})),
user_rows: mockClientAccess.flatMap((client) =>
client.users.map((user) => ({
client_id: client.id,
client_name: client.name,
user_id: user.id,
full_name: user.full_name,
email: user.email,
role: user.role,
status: user.status,
is_new_user: user.is_new_user,
last_login_at: user.last_login_at,
created_at: user.created_at
}))
),
feature_rows: mockClientAccess.flatMap((client) =>
client.features.map((feature) => ({
client_id: client.id,
client_name: client.name,
feature_id: feature.id,
feature_key: feature.feature_key,
feature_name: feature.feature_name,
feature_group: feature.feature_group,
enabled: feature.enabled,
updated_at: feature.updated_at
}))
),
clients: mockClientAccess
};
+34 -14
View File
@@ -1,58 +1,78 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export type OperatorSession = { export type AppSession = {
name: string; name: string;
email: string; email: string;
role: string; role: string;
token: string;
tenant_id?: string | null;
}; };
const STORAGE_KEY = 'data-entry-app-operator-session'; const CLIENT_STORAGE_KEY = 'data-entry-app-client-session';
const ADMIN_STORAGE_KEY = 'data-entry-app-admin-session';
function readSession(): OperatorSession | null { function readStoredSession(storageKey: string): AppSession | null {
if (!browser) { if (!browser) {
return null; return null;
} }
const value = localStorage.getItem(STORAGE_KEY); const value = localStorage.getItem(storageKey);
if (!value) { if (!value) {
return null; return null;
} }
try { try {
return JSON.parse(value) as OperatorSession; return JSON.parse(value) as AppSession;
} catch { } catch {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(storageKey);
return null; return null;
} }
} }
function createOperatorSessionStore() { function createSessionStore(storageKey: string) {
const store = writable<OperatorSession | null>(readSession()); const store = writable<AppSession | null>(readStoredSession(storageKey));
if (browser) { if (browser) {
window.addEventListener('storage', (event) => { window.addEventListener('storage', (event) => {
if (event.key === STORAGE_KEY) { if (event.key === storageKey) {
store.set(readSession()); store.set(readStoredSession(storageKey));
} }
}); });
} }
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
set(session: OperatorSession) { set(session: AppSession) {
if (browser) { if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); localStorage.setItem(storageKey, JSON.stringify(session));
} }
store.set(session); store.set(session);
}, },
clear() { clear() {
if (browser) { if (browser) {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(storageKey);
} }
store.set(null); store.set(null);
} }
}; };
} }
export const operatorSession = createOperatorSessionStore(); export function getStoredClientSession() {
return readStoredSession(CLIENT_STORAGE_KEY);
}
export function getStoredAdminSession() {
return readStoredSession(ADMIN_STORAGE_KEY);
}
export function hasStoredClientSession() {
return getStoredClientSession() !== null;
}
export function hasStoredAdminSession() {
return getStoredAdminSession() !== null;
}
export const clientSession = createSessionStore(CLIENT_STORAGE_KEY);
export const adminSession = createSessionStore(ADMIN_STORAGE_KEY);
+98
View File
@@ -48,6 +48,34 @@ export type Mix = {
warnings: string[]; warnings: string[];
}; };
export type MixIngredientInput = {
raw_material_id: number;
quantity_kg: number;
notes?: string | null;
};
export type MixCreateInput = {
client_name: string;
name: string;
status?: string;
version?: number;
notes?: string | null;
ingredients: MixIngredientInput[];
};
export type MixUpdateInput = {
client_name?: string;
name?: string;
status?: string;
version?: number;
notes?: string | null;
};
export type MixIngredientUpdateInput = {
quantity_kg?: number;
notes?: string | null;
};
export type Product = { export type Product = {
id: number; id: number;
tenant_id?: string; tenant_id?: string;
@@ -90,10 +118,63 @@ export type Scenario = {
overrides: Record<string, unknown>; overrides: Record<string, unknown>;
}; };
export type ClientAccessUser = {
id: number;
client_account_id: number;
full_name: string;
email: string;
role: string;
status: string;
is_new_user: boolean;
last_login_at?: string | null;
created_at: string;
};
export type ClientAccessFeature = {
id: number;
client_account_id: number;
feature_key: string;
feature_name: string;
feature_group: string;
description?: string | null;
enabled: boolean;
updated_at: string;
created_at: string;
};
export type ClientAccessAccount = {
id: number;
tenant_id: string;
name: string;
client_code: string;
status: string;
powerbi_workspace?: string | null;
notes?: string | null;
created_at: string;
users: ClientAccessUser[];
features: ClientAccessFeature[];
active_user_count: number;
new_user_count: number;
enabled_feature_count: number;
total_feature_count: number;
};
export type ClientAccessExportRow = Record<string, unknown>;
export type ClientAccessPowerBiExport = {
generated_at: string;
client_rows: ClientAccessExportRow[];
user_rows: ClientAccessExportRow[];
feature_rows: ClientAccessExportRow[];
clients: ClientAccessAccount[];
};
export type LoginResponse = { export type LoginResponse = {
name: string; name: string;
email: string; email: string;
role: string; role: string;
token: string;
tenant_id?: string | null;
}; };
export type RawMaterialCreateInput = { export type RawMaterialCreateInput = {
@@ -119,3 +200,20 @@ export type RawMaterialPriceCreateInput = {
status?: string; status?: string;
notes?: string | null; notes?: string | null;
}; };
export type ClientUserCreateInput = {
client_account_id: number;
full_name: string;
email: string;
role?: string;
status?: string;
is_new_user?: boolean;
};
export type ClientUserUpdateInput = {
full_name?: string;
email?: string;
role?: string;
status?: string;
is_new_user?: boolean;
};
+14 -189
View File
@@ -1,194 +1,19 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { operatorSession } from '$lib/session'; import AdminShell from '$lib/components/AdminShell.svelte';
import ClientShell from '$lib/components/ClientShell.svelte';
const links = [ let { children } = $props();
{ href: '/', label: 'Home' },
{ href: '/raw-materials', label: 'Raw Materials' }, const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
{ href: '/mixes', label: 'Mix Master' },
{ href: '/products', label: 'Products' },
{ href: '/scenarios', label: 'Scenarios' }
];
</script> </script>
<svelte:head> {#if isAdminRoute}
<title>Data Entry App</title> <AdminShell>
</svelte:head> {@render children()}
</AdminShell>
<div class="shell"> {:else}
<header class="topbar"> <ClientShell>
<div class="brand-block"> {@render children()}
<a class="brand" href="/">Data Entry App</a> </ClientShell>
<p>Operator costing workflow</p> {/if}
</div>
<nav class="topnav" aria-label="Primary navigation">
{#each links as link}
<a class:active={page.url.pathname === link.href} href={link.href}>{link.label}</a>
{/each}
</nav>
<div class="session-panel">
{#if $operatorSession}
<div>
<span>Signed in</span>
<strong>{$operatorSession.name}</strong>
</div>
<button type="button" onclick={() => operatorSession.clear()}>Sign out</button>
{:else}
<a class="login-link" href="/">Operator login</a>
{/if}
</div>
</header>
<main class="content">
<slot />
</main>
</div>
<style>
:global(:root) {
--canvas: #f5efe4;
--canvas-strong: #fffaf1;
--ink: #20170f;
--muted: #695746;
--line: rgba(74, 53, 31, 0.14);
--brand: #8f4f1f;
--brand-deep: #5a2d18;
--accent: #d9a441;
--shadow: 0 18px 50px rgba(56, 38, 19, 0.12);
}
:global(body) {
margin: 0;
color: var(--ink);
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
background:
radial-gradient(circle at top right, rgba(217, 164, 65, 0.22), transparent 24rem),
radial-gradient(circle at left center, rgba(143, 79, 31, 0.1), transparent 28rem),
linear-gradient(180deg, #f7f1e7 0%, #efe5d3 100%);
}
:global(*) {
box-sizing: border-box;
}
.shell {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
padding: 1rem 2rem;
backdrop-filter: blur(16px);
background: rgba(247, 241, 231, 0.88);
border-bottom: 1px solid var(--line);
}
.brand-block {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.brand {
color: var(--brand-deep);
text-decoration: none;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.brand-block p,
.session-panel span {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.topnav {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.65rem;
}
.topnav a,
.login-link,
button {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.75rem 1rem;
color: var(--brand-deep);
text-decoration: none;
background: transparent;
font: inherit;
cursor: pointer;
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.topnav a:hover,
.topnav a.active,
.login-link:hover,
button:hover {
background: rgba(143, 79, 31, 0.08);
border-color: rgba(143, 79, 31, 0.18);
transform: translateY(-1px);
}
.topnav a.active {
background: linear-gradient(135deg, rgba(143, 79, 31, 0.14), rgba(217, 164, 65, 0.18));
font-weight: 600;
}
.session-panel {
display: flex;
align-items: center;
gap: 0.9rem;
}
.session-panel strong {
display: block;
}
button {
background: var(--brand-deep);
color: #fff7ef;
box-shadow: var(--shadow);
}
button:hover {
background: #472213;
}
.content {
padding: 2rem;
}
@media (max-width: 980px) {
.topbar {
grid-template-columns: 1fr;
justify-items: start;
padding: 1rem 1rem 0.9rem;
}
.topnav {
justify-content: flex-start;
}
.content {
padding: 1rem;
}
}
</style>
File diff suppressed because it is too large Load Diff
+35 -15
View File
@@ -1,20 +1,40 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load() {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([ if (!hasStoredClientSession()) {
api.rawMaterials(), return {
api.mixes(), rawMaterials: [],
api.productCosts(), mixes: [],
api.scenarios(), productCosts: [],
api.dataQuality() scenarios: [],
]); dataQuality: []
};
}
return { try {
rawMaterials, const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
mixes, api.rawMaterials(),
productCosts, api.mixes(),
scenarios, api.productCosts(),
dataQuality api.scenarios(),
}; api.dataQuality()
]);
return {
rawMaterials,
mixes,
productCosts,
scenarios,
dataQuality
};
} catch {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
}
} }
+381
View File
@@ -0,0 +1,381 @@
<script lang="ts">
import { api } from '$lib/api';
import { adminSession } from '$lib/session';
let { data } = $props();
let email = $state('admin@lean101.local');
let password = $state('lean101-admin');
let isLoggingIn = $state(false);
let loginError = $state('');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No preview generated';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
async function handleLogin(event: SubmitEvent) {
event.preventDefault();
loginError = '';
isLoggingIn = true;
try {
const session = await api.adminLogin(email, password);
adminSession.set(session);
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
} finally {
isLoggingIn = false;
}
}
const totalUsers = $derived(data.clients.reduce((sum, client) => sum + client.users.length, 0));
const totalFeatures = $derived(data.clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
</script>
<section class="hero-card">
<div class="hero-copy">
<p class="eyebrow">Lean 101 Admin Panel</p>
<h2>Separate operator login and client access controls from the Hunter Premium Produce workspace.</h2>
<p>Use this admin surface for internal access changes, export validation, and operator-only workflows.</p>
</div>
<div class="hero-stats">
<article>
<span>Managed clients</span>
<strong>{data.clients.length}</strong>
</article>
<article>
<span>Total users</span>
<strong>{totalUsers}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{totalFeatures}</strong>
</article>
</div>
</section>
{#if !$adminSession}
<section class="signin-card">
<div class="signin-copy">
<p class="eyebrow">Admin Sign-In</p>
<h3>Authenticate here to unlock the admin navigation and client access controls.</h3>
<p>The public client workspace no longer exposes this operator sign-in.</p>
</div>
<form class="signin-form" onsubmit={handleLogin}>
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" />
<input bind:value={password} type="password" autocomplete="current-password" placeholder="Password" />
<button class="primary-button" type="submit" disabled={isLoggingIn}>
{isLoggingIn ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div class="signin-meta">
{#if loginError}
<strong>{loginError}</strong>
{/if}
</div>
</section>
{:else}
<section class="live-banner">
<div>
<p class="eyebrow">Session Active</p>
<h3>{$adminSession.name} is signed in to the Lean 101 Admin Panel.</h3>
<p>Open the client access workspace to manage users, feature flags, and the Power BI export preview.</p>
</div>
<div class="live-actions">
<a class="primary-button" href="/admin/client-access">Open Client Access</a>
<a class="secondary-button" href="/">View Hunter workspace</a>
</div>
</section>
{/if}
<section class="detail-grid">
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Scope</p>
<h3>What belongs in admin</h3>
</div>
</div>
<div class="bullet-list">
<article>
<strong>Client access control</strong>
<span>Manage new users, existing users, and feature access by client.</span>
</article>
<article>
<strong>Power BI export validation</strong>
<span>Verify the live export payload after each access change.</span>
</article>
<article>
<strong>Operator-only sign-in</strong>
<span>Keep internal authentication separate from the client workspace at `/`.</span>
</article>
</div>
</article>
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Preview Snapshot</p>
<h3>Current export summary</h3>
<p>Last generated {formatDate(data.exportPreview.generated_at)}</p>
</div>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{data.exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{data.exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{data.exportPreview.feature_rows.length}</strong>
</article>
</div>
</article>
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #6e8576;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.06);
}
.hero-card,
.signin-card,
.live-banner,
.detail-grid {
margin-bottom: 1.25rem;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
padding: 1.25rem;
}
.hero-card,
.hero-stats,
.detail-grid,
.preview-stats {
display: grid;
gap: 1rem;
}
.hero-card {
grid-template-columns: minmax(0, 1.2fr) 0.85fr;
align-items: end;
}
.hero-copy h2 {
margin: 0.35rem 0 0.45rem;
max-width: 16ch;
font-size: clamp(2rem, 3vw, 2.6rem);
line-height: 1.02;
}
.hero-copy p:last-child,
.signin-copy p:last-child,
.live-banner p:last-child,
.bullet-list span,
.preview-stats span,
.card-toolbar p {
color: #5f7266;
}
.hero-stats,
.preview-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hero-stats article,
.preview-stats article {
padding: 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.hero-stats span,
.preview-stats span {
display: block;
font-size: 0.84rem;
}
.hero-stats strong,
.preview-stats strong {
display: block;
margin-top: 0.35rem;
font-size: 1.8rem;
}
.signin-card {
display: grid;
grid-template-columns: 1.2fr 1fr auto;
gap: 1rem;
align-items: center;
}
.signin-copy h3,
.live-banner h3,
.card-toolbar h3 {
margin: 0.28rem 0 0.35rem;
font-size: 1.2rem;
}
.signin-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.signin-form input {
width: 100%;
padding: 0.9rem 0.95rem;
border: 1px solid rgba(34, 54, 45, 0.12);
border-radius: 0.85rem;
background: rgba(248, 251, 249, 0.92);
}
.signin-meta {
display: grid;
gap: 0.35rem;
justify-items: end;
color: #5f7266;
font-size: 0.9rem;
}
.signin-meta strong {
color: #b33636;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
font-weight: 600;
text-decoration: none;
}
.primary-button {
border: none;
color: #fff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
box-shadow: 0 8px 20px rgba(32, 48, 40, 0.18);
}
.secondary-button {
border: 1px solid rgba(34, 54, 45, 0.12);
color: #203028;
background: rgba(255, 255, 255, 0.9);
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.live-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.live-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.detail-grid {
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.9fr);
}
.card-toolbar {
margin-bottom: 1rem;
}
.bullet-list {
display: grid;
gap: 0.9rem;
}
.bullet-list article {
padding: 0.95rem 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.bullet-list strong,
.preview-stats strong {
font-weight: 700;
}
.bullet-list span {
display: block;
margin-top: 0.25rem;
}
@media (max-width: 1120px) {
.hero-card,
.signin-card,
.detail-grid,
.hero-stats,
.preview-stats {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.live-banner {
flex-direction: column;
align-items: flex-start;
}
.signin-form {
grid-template-columns: 1fr;
}
}
</style>
+37
View File
@@ -0,0 +1,37 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
}
@@ -0,0 +1,7 @@
<script lang="ts">
import ClientAccessWorkspace from '$lib/components/ClientAccessWorkspace.svelte';
let { data } = $props();
</script>
<ClientAccessWorkspace {data} />
@@ -0,0 +1,37 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
}
@@ -0,0 +1,857 @@
<script lang="ts">
import { api } from '$lib/api';
import type { ClientAccessAccount, ClientAccessFeature, ClientAccessPowerBiExport } from '$lib/types';
let { data } = $props();
let clients = $state<ClientAccessAccount[]>([]);
let exportPreview = $state<ClientAccessPowerBiExport>({
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
});
let selectedClientId = $state(0);
let fullName = $state('');
let email = $state('');
let role = $state('viewer');
let status = $state('invited');
let isNewUser = $state(true);
let formError = $state('');
let formSuccess = $state('');
let isSubmitting = $state(false);
let savingUserId = $state<number | null>(null);
let savingFeatureId = $state<number | null>(null);
let previewStatus = $state('Live preview loaded');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No activity yet';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
function initials(value: string) {
return value
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
function replaceClient(updatedClient: ClientAccessAccount) {
clients = clients.map((client) => (client.id === updatedClient.id ? updatedClient : client));
}
async function refreshExportPreview() {
exportPreview = await api.clientAccessExport();
previewStatus = `Preview refreshed ${formatDate(exportPreview.generated_at)}`;
}
async function handleCreateUser(event: SubmitEvent) {
event.preventDefault();
formError = '';
formSuccess = '';
if (!selectedClientId) {
formError = 'Select a client before creating a user.';
return;
}
if (!fullName.trim() || !email.trim()) {
formError = 'Name and email are required.';
return;
}
isSubmitting = true;
try {
const updatedClient = await api.createClientUser({
client_account_id: selectedClientId,
full_name: fullName.trim(),
email: email.trim(),
role,
status,
is_new_user: isNewUser
});
replaceClient(updatedClient);
await refreshExportPreview();
fullName = '';
email = '';
role = 'viewer';
status = 'invited';
isNewUser = true;
formSuccess = 'User created and included in the export preview.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to create client user';
} finally {
isSubmitting = false;
}
}
async function updateUser(userId: number, payload: { role?: string; status?: string; is_new_user?: boolean }) {
savingUserId = userId;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientUser(userId, payload);
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = 'User access updated.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update client user';
} finally {
savingUserId = null;
}
}
async function toggleFeature(feature: ClientAccessFeature) {
savingFeatureId = feature.id;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientFeature(feature.id, { enabled: !feature.enabled });
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = `${feature.feature_name} ${feature.enabled ? 'disabled' : 'enabled'}.`;
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update feature access';
} finally {
savingFeatureId = null;
}
}
$effect(() => {
if (!clients.length && data.clients.length) {
clients = structuredClone(data.clients) as ClientAccessAccount[];
}
if (!exportPreview.generated_at && data.exportPreview.generated_at) {
exportPreview = structuredClone(data.exportPreview) as ClientAccessPowerBiExport;
}
if (!selectedClientId && data.clients[0]) {
selectedClientId = data.clients[0].id;
}
});
const selectedClient = $derived(clients.find((client) => client.id === selectedClientId) ?? clients[0]);
const totalUsers = $derived(clients.reduce((sum, client) => sum + client.users.length, 0));
const totalEnabledFeatures = $derived(clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
const previewJson = $derived(JSON.stringify(exportPreview, null, 2));
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Client Amend Area</p>
<h2>Control new users, existing users, and every feature flag in one operational workspace.</h2>
<p>The preview shows the live Power BI export payload after each amendment so the admin surface and reporting output stay aligned.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Clients</span>
<strong>{clients.length}</strong>
<p>Accounts currently staged in the client app</p>
</article>
<article class="metric-card">
<span>Total Users</span>
<strong>{totalUsers}</strong>
<p>New and existing users across every client</p>
</article>
<article class="metric-card">
<span>Enabled Features</span>
<strong>{totalEnabledFeatures}</strong>
<p>Feature switches currently turned on</p>
</article>
</section>
<section class="workspace-grid">
<article class="surface-card client-list-card">
<div class="card-toolbar">
<div>
<h3>Clients</h3>
<p>Select a client before amending users or feature access.</p>
</div>
</div>
<div class="client-list">
{#each clients as client}
<button
class:selected={client.id === selectedClient?.id}
class="client-row"
type="button"
onclick={() => {
selectedClientId = client.id;
formError = '';
formSuccess = '';
}}
>
<div class="client-row-head">
<span class="client-badge">{client.client_code}</span>
<div>
<strong>{client.name}</strong>
<span>{client.tenant_id}</span>
</div>
</div>
<div class="client-row-meta">
<span class={`status-pill ${client.status === 'active' ? 'positive' : 'neutral'}`}>{client.status}</span>
<small>{client.active_user_count} active users</small>
</div>
</button>
{/each}
</div>
</article>
<article class="surface-card amend-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Selected Client</p>
<h3>{selectedClient?.name ?? 'No client selected'}</h3>
<p>{selectedClient?.powerbi_workspace ?? 'No Power BI workspace assigned yet.'}</p>
</div>
{#if selectedClient}
<span class={`status-pill ${selectedClient.status === 'active' ? 'positive' : 'neutral'}`}>{selectedClient.status}</span>
{/if}
</div>
<div class="client-summary">
<article>
<span>Existing users</span>
<strong>{selectedClient ? selectedClient.users.length - selectedClient.new_user_count : 0}</strong>
</article>
<article>
<span>New users</span>
<strong>{selectedClient?.new_user_count ?? 0}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{selectedClient?.enabled_feature_count ?? 0}/{selectedClient?.total_feature_count ?? 0}</strong>
</article>
</div>
<form class="create-user-form" onsubmit={handleCreateUser}>
<div class="section-title">
<h4>Add New User</h4>
<span>Creates the user and immediately updates the export preview.</span>
</div>
<div class="form-grid">
<label>
<span>Full name</span>
<input bind:value={fullName} placeholder="Jordan Lee" />
</label>
<label>
<span>Email</span>
<input bind:value={email} type="email" placeholder="jordan.lee@client.example" />
</label>
<label>
<span>Role</span>
<select bind:value={role}>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</label>
<label>
<span>Status</span>
<select bind:value={status}>
<option value="invited">Invited</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
</label>
</div>
<label class="toggle-row">
<div>
<strong>Mark as new user</strong>
<span>Controls the onboarding signal carried into the export.</span>
</div>
<input bind:checked={isNewUser} type="checkbox" />
</label>
<div class="form-actions">
<button class="primary-button" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving user...' : 'Create User'}
</button>
{#if formError}
<strong class="message error">{formError}</strong>
{/if}
{#if !formError && formSuccess}
<strong class="message success">{formSuccess}</strong>
{/if}
</div>
</form>
<div class="section-title">
<h4>Existing Users</h4>
<span>Roles, lifecycle state, and new-user status can be amended inline.</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>New User</th>
<th>Last Login</th>
</tr>
</thead>
<tbody>
{#each selectedClient?.users ?? [] as user}
<tr>
<td class="user-cell">
<div class="user-item">
<span class="user-badge">{initials(user.full_name)}</span>
<div>
<strong>{user.full_name}</strong>
<span>{user.email}</span>
</div>
</div>
</td>
<td>
<select
value={user.role}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { role: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</td>
<td>
<select
value={user.status}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { status: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="active">Active</option>
<option value="invited">Invited</option>
<option value="suspended">Suspended</option>
</select>
</td>
<td>
<label class="inline-toggle">
<input
checked={user.is_new_user}
disabled={savingUserId === user.id}
type="checkbox"
onchange={(event) =>
updateUser(user.id, { is_new_user: (event.currentTarget as HTMLInputElement).checked })}
/>
<span>{user.is_new_user ? 'New' : 'Existing'}</span>
</label>
</td>
<td>
<div class="date-block">
<strong>{user.status}</strong>
<span>{formatDate(user.last_login_at)}</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</article>
<article class="surface-card feature-card">
<div class="card-toolbar">
<div>
<h3>Feature Access</h3>
<p>Every client feature can be switched on or off independently.</p>
</div>
</div>
<div class="feature-list">
{#each selectedClient?.features ?? [] as feature}
<article class="feature-row">
<div>
<div class="feature-head">
<strong>{feature.feature_name}</strong>
<span>{feature.feature_group}</span>
</div>
<p>{feature.description}</p>
</div>
<button
class:enabled={feature.enabled}
class="feature-toggle"
type="button"
disabled={savingFeatureId === feature.id}
onclick={() => toggleFeature(feature)}
>
<span>{savingFeatureId === feature.id ? 'Saving...' : feature.enabled ? 'On' : 'Off'}</span>
</button>
</article>
{/each}
</div>
</article>
</section>
<section class="preview-grid">
<article class="surface-card preview-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Power BI Preview</p>
<h3>Export Shape</h3>
<p>{previewStatus}</p>
</div>
<span class="endpoint-pill">GET /api/powerbi/client-access</span>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{exportPreview.feature_rows.length}</strong>
</article>
</div>
<pre>{previewJson}</pre>
</article>
</section>
<style>
h2,
h3,
h4,
p,
pre {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.workspace-grid,
.preview-grid {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.card-toolbar p,
.client-row span,
.section-title span,
.feature-row p,
.feature-head span,
.date-block span,
.message,
pre {
color: var(--muted);
}
.metric-row,
.workspace-grid,
.preview-stats,
.client-summary,
.form-grid {
display: grid;
gap: 1rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.workspace-grid {
grid-template-columns: 0.78fr 1.5fr 1fr;
align-items: start;
}
.preview-stats,
.client-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card,
.surface-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.35rem;
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;
font-weight: 700;
}
.surface-card {
padding: 1.2rem;
}
.card-toolbar,
.client-row,
.client-row-head,
.client-row-meta,
.section-title,
.feature-row,
.form-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.card-toolbar,
.section-title {
margin-bottom: 1rem;
}
.card-toolbar h3,
.section-title h4 {
font-weight: 700;
}
.client-list,
.feature-list {
display: grid;
gap: 0.75rem;
}
.client-row {
width: 100%;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
text-align: left;
cursor: pointer;
}
.client-row.selected {
border-color: #b9dfc6;
background: var(--green-soft);
}
.client-row strong,
.user-item strong,
.feature-head strong,
.date-block strong {
display: block;
font-size: 0.96rem;
}
.client-badge,
.user-badge {
width: 2.45rem;
height: 2.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.8rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.client-summary {
margin-bottom: 1rem;
}
.client-summary article,
.preview-stats article {
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.client-summary span,
.preview-stats span {
display: block;
margin-bottom: 0.28rem;
color: var(--muted);
font-size: 0.84rem;
}
.client-summary strong,
.preview-stats strong {
font-size: 1.3rem;
font-weight: 700;
}
.create-user-form {
margin-bottom: 1.2rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
label {
display: grid;
gap: 0.4rem;
}
label span,
.toggle-row span {
font-size: 0.84rem;
color: var(--muted);
}
input,
select {
width: 100%;
padding: 0.82rem 0.88rem;
border: 1px solid var(--line-strong);
border-radius: 0.82rem;
background: #fff;
color: var(--text);
}
.toggle-row,
.inline-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.toggle-row {
margin-top: 1rem;
}
.toggle-row input,
.inline-toggle input {
width: auto;
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.2);
font-weight: 600;
cursor: pointer;
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.message.error {
color: #b33636;
}
.message.success {
color: var(--green-deep);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
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;
}
.user-cell {
min-width: 19rem;
}
.user-item,
.feature-head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-pill,
.endpoint-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.feature-row {
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.feature-toggle {
min-width: 4.6rem;
padding: 0.72rem 0.8rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: #fff;
color: #5a6c63;
font-weight: 700;
cursor: pointer;
}
.feature-toggle.enabled {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
}
.feature-toggle:disabled {
opacity: 0.7;
cursor: wait;
}
.endpoint-pill {
color: #245961;
background: var(--blue-soft);
}
pre {
margin-top: 1rem;
padding: 1rem;
border-radius: 1rem;
background: #18231d;
border: 1px solid #1f3028;
color: #d6e4dc;
overflow: auto;
font-size: 0.82rem;
line-height: 1.55;
max-height: 34rem;
}
@media (max-width: 1220px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 960px) {
.metric-row,
.preview-stats,
.client-summary,
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.card-toolbar,
.client-row,
.feature-row,
.form-actions,
.section-title {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/admin/client-access');
}
+360 -75
View File
@@ -1,96 +1,381 @@
<script lang="ts"> <script lang="ts">
let { data } = $props(); let { data } = $props();
let activeMenuId = $state<number | null>(null);
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
const warningCount = $derived(data.mixes.reduce((sum, mix) => sum + mix.warnings.length, 0));
const averageCost = $derived(
data.mixes.length
? data.mixes.reduce((sum, mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
: 0
);
</script> </script>
<section class="panel"> <section class="page-intro">
<h2>Mix Master</h2> <div>
<p>Recipes are structured as ingredient rows instead of spreadsheet columns.</p> <p class="eyebrow">Mix Master</p>
<h2>Saved mixes in a clean table view.</h2>
<p>Use the table to browse mixes, then open a dedicated worksheet page to edit or create a formulation.</p>
</div>
<div class="cards"> <div class="intro-actions">
{#each data.mixes as mix} <a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
<article class="card"> </div>
<div class="card-header"> </section>
<div>
<h3>{mix.name}</h3> <section class="metric-row">
<p>{mix.client_name}</p> <article class="metric-card">
</div> <span>Total Mixes</span>
<span>{mix.status}</span> <strong>{data.mixes.length}</strong>
</div> <p>Saved mix definitions</p>
<dl> </article>
<div>
<dt>Total Kg</dt> <article class="metric-card">
<dd>{mix.total_mix_kg}</dd> <span>Average Cost / Kg</span>
</div> <strong>{currency(averageCost, 4)}</strong>
<div> <p>Across all saved mixes</p>
<dt>Total Cost</dt> </article>
<dd>${mix.total_mix_cost.toFixed(2)}</dd>
</div> <article class="metric-card">
<div> <span>Warnings</span>
<dt>Cost/Kg</dt> <strong>{warningCount}</strong>
<dd>{mix.mix_cost_per_kg ? `$${mix.mix_cost_per_kg.toFixed(4)}` : 'N/A'}</dd> <p>Mixes needing review</p>
</div> </article>
</dl> </section>
{#if mix.warnings.length}
<ul> <section class="table-card">
{#each mix.warnings as warning} <div class="section-heading">
<li>{warning}</li> <div>
{/each} <p class="eyebrow">Table View</p>
</ul> <h3>Saved mixes</h3>
{/if} </div>
</article> <span class="soft-pill">Open any mix to edit</span>
{/each} </div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Mix</th>
<th>Client</th>
<th>Ingredients</th>
<th>Total Kg</th>
<th>Total Cost</th>
<th>Cost / Kg</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each data.mixes as mix}
<tr>
<td>
<div class="table-item">
<span class="row-badge">MX</span>
<div>
<strong>{mix.name}</strong>
<span>v{mix.version ?? 1}</span>
</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>
<span class={`status-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span>
</td>
<td class="menu-cell">
<div class="menu-wrap">
<button class="menu-trigger" type="button" onclick={() => (activeMenuId = activeMenuId === mix.id ? null : mix.id)}>
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>
{/each}
</tbody>
</table>
</div> </div>
</section> </section>
<style> <style>
.panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1rem;
}
.card {
background: rgba(255, 251, 244, 0.82);
border-radius: 1.2rem;
padding: 1.1rem;
border: 1px solid rgba(91, 69, 40, 0.12);
}
.card-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
h2, h2,
h3, h3,
p, p {
dl,
dd {
margin: 0; margin: 0;
} }
dl { .eyebrow {
color: #7f8e85;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.table-card {
margin-bottom: 1.12rem;
}
.page-intro,
.metric-card,
.table-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.16rem;
box-shadow: var(--shadow);
}
.page-intro,
.table-card {
padding: 1.08rem;
}
.page-intro,
.section-heading,
.intro-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.68rem;
}
.page-intro {
align-items: end;
}
.page-intro h2 {
margin: 0.3rem 0 0.4rem;
font-size: clamp(1.56rem, 3vw, 2.02rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.table-item span:last-child {
color: var(--muted);
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.82rem;
padding: 0.74rem 0.9rem;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
}
.metric-row {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem; gap: 0.9rem;
margin-top: 1rem;
} }
dt { .metric-card {
color: #5f5245; padding: 1.04rem 1.08rem;
font-size: 0.85rem;
} }
ul { .metric-card span {
margin: 1rem 0 0; display: block;
padding-left: 1rem; color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.48rem 0 0.26rem;
font-size: 1.72rem;
font-weight: 700;
}
.section-heading {
align-items: flex-start;
margin-bottom: 0.9rem;
}
.section-heading h3 {
font-size: 1.02rem;
font-weight: 700;
}
.soft-pill {
padding: 0.42rem 0.72rem;
border-radius: 999px;
color: var(--green-deep);
background: var(--green-soft);
font-size: 0.82rem;
font-weight: 600;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0 0.54rem;
}
th,
td {
padding: 0.88rem 0.92rem;
text-align: left;
white-space: nowrap;
}
th {
color: var(--muted);
font-size: 0.74rem;
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: 0.92rem 0 0 0.92rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 0.92rem 0.92rem 0;
}
.table-item {
display: flex;
align-items: center;
gap: 0.74rem;
}
.table-item strong {
display: block;
font-size: 0.94rem;
}
.table-item span:last-child {
display: block;
margin-top: 0.15rem;
font-size: 0.8rem;
}
.row-badge {
width: 2.04rem;
height: 2.04rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.72rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.38rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: capitalize;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.warning {
color: #a9681d;
background: #fff6e6;
}
.menu-cell {
width: 1%;
}
.menu-wrap {
position: relative;
}
.menu-trigger {
border: 1px solid var(--line-strong);
border-radius: 0.76rem;
padding: 0.6rem 0.74rem;
color: #304038;
background: #fff;
font-weight: 600;
cursor: pointer;
}
.menu-panel {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 10;
min-width: 10rem;
display: grid;
gap: 0.18rem;
padding: 0.32rem;
border: 1px solid var(--line);
border-radius: 0.84rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
}
.menu-panel a {
padding: 0.64rem 0.72rem;
border-radius: 0.7rem;
}
.menu-panel a:hover {
background: var(--panel-soft);
}
@media (max-width: 980px) {
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-intro,
.section-heading {
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>
+16 -4
View File
@@ -1,8 +1,20 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load() {
return { if (!hasStoredClientSession()) {
mixes: await api.mixes() return {
}; mixes: []
} };
}
try {
return {
mixes: await api.mixes()
};
} catch {
return {
mixes: []
};
}
}
@@ -0,0 +1,7 @@
<script lang="ts">
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
let { data } = $props();
</script>
<MixWorkspace rawMaterials={data.rawMaterials} initialMix={data.mix} />
+29
View File
@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import { api } from '$lib/api';
import { hasStoredClientSession } from '$lib/session';
export async function load({ params }) {
const mixId = Number(params.id);
if (!Number.isFinite(mixId)) {
throw error(404, 'Mix not found');
}
if (!hasStoredClientSession()) {
return {
mix: null,
rawMaterials: []
};
}
try {
const [mix, rawMaterials] = await Promise.all([api.mix(mixId), api.rawMaterials()]);
return {
mix,
rawMaterials
};
} catch {
throw error(404, 'Mix not found');
}
}
@@ -0,0 +1,7 @@
<script lang="ts">
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
let { data } = $props();
</script>
<MixWorkspace rawMaterials={data.rawMaterials} />
+20
View File
@@ -0,0 +1,20 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
if (!hasStoredClientSession()) {
return {
rawMaterials: []
};
}
try {
return {
rawMaterials: await api.rawMaterials()
};
} catch {
return {
rawMaterials: []
};
}
}
+340 -37
View File
@@ -1,59 +1,362 @@
<script lang="ts"> <script lang="ts">
let { data } = $props(); let { data } = $props();
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
function initials(name: string) {
return name
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
const rows = $derived(
data.products.map((product) => {
const cost = data.productCosts.find((item) => item.product_id === product.id);
return {
...product,
cost,
health: cost?.warnings.length ? 'Review' : 'Healthy',
healthTone: cost?.warnings.length ? 'warning' : 'positive'
};
})
);
const highestDelivered = $derived(
rows.reduce(
(best, row) =>
(row.cost?.finished_product_delivered ?? 0) > (best.cost?.finished_product_delivered ?? 0) ? row : best,
rows[0]
)
);
const averageDelivered = $derived(
rows.length
? rows.reduce((sum, row) => sum + (row.cost?.finished_product_delivered ?? 0), 0) / rows.length
: 0
);
</script> </script>
<section class="panel"> <section class="page-intro">
<h2>Products</h2> <div>
<p>Transparent delivered cost and pricing outputs from backend calculations.</p> <p class="eyebrow">Output Pricing</p>
<table> <h2>Products1</h2>
<thead> <p>Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.</p>
<tr> </div>
<th>Product</th> </section>
<th>Mix</th>
<th>Sale Type</th> <section class="metric-row">
<th>Delivered Cost</th> <article class="metric-card">
<th>Distributor</th> <span>Total Products</span>
<th>Wholesale</th> <strong>{rows.length}</strong>
</tr> <p>Active finished outputs</p>
</thead> </article>
<tbody>
{#each data.products as product} <article class="metric-card">
{@const cost = data.productCosts.find((item) => item.product_id === product.id)} <span>Highest Delivered Cost</span>
<strong>{currency(highestDelivered?.cost?.finished_product_delivered)}</strong>
<p>{highestDelivered?.name ?? 'No product loaded'}</p>
</article>
<article class="metric-card">
<span>Average Delivered Cost</span>
<strong>{currency(averageDelivered)}</strong>
<p>Across all tracked products</p>
</article>
</section>
<section class="table-card">
<div class="table-toolbar">
<div>
<h3>Product Price Table</h3>
<p>Modern row groups with quick-read badges and healthier spacing.</p>
</div>
<button type="button">Pricing View</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr> <tr>
<td>{product.name}</td> <th>Product</th>
<td>{product.mix_name}</td> <th>Mix</th>
<td>{product.sale_type}</td> <th>Sale Type</th>
<td>{cost ? `$${cost.finished_product_delivered.toFixed(2)}` : 'N/A'}</td> <th>Delivered</th>
<td>{cost?.distributor_price ? `$${cost.distributor_price.toFixed(2)}` : 'N/A'}</td> <th>Margins</th>
<td>{cost?.wholesale_price ? `$${cost.wholesale_price.toFixed(2)}` : 'N/A'}</td> <th>Health</th>
</tr> </tr>
{/each} </thead>
</tbody> <tbody>
</table> {#each rows as row}
<tr>
<td class="product-cell">
<div class="product-item">
<span class="product-badge">{initials(row.name)}</span>
<div>
<strong>{row.name}</strong>
<span>{row.client_name}</span>
</div>
</div>
</td>
<td>
<div class="mix-block">
<strong>{row.mix_name}</strong>
<span>{row.unit_of_measure}</span>
</div>
</td>
<td>
<span class="sale-pill">{row.sale_type}</span>
</td>
<td>
<div class="number-block">
<strong>{currency(row.cost?.finished_product_delivered)}</strong>
<span>Delivered cost</span>
</div>
</td>
<td>
<div class="number-block">
<strong>{currency(row.cost?.distributor_price)} / {currency(row.cost?.wholesale_price)}</strong>
<span>Distributor / wholesale</span>
</div>
</td>
<td>
<span class={`status-pill ${row.healthTone}`}>{row.health}</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section> </section>
<style> <style>
.panel { h2,
background: rgba(255, 251, 244, 0.82); h3,
border-radius: 1.2rem; p {
padding: 1.2rem; margin: 0;
border: 1px solid rgba(91, 69, 40, 0.12);
} }
h2, .eyebrow {
p { color: #7d8d84;
margin-top: 0; 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 h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.table-toolbar p,
.product-item span,
.mix-block span,
.number-block span {
color: var(--muted);
}
.metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.metric-card,
.table-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.3rem;
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;
font-weight: 700;
}
.table-card {
padding: 1.2rem;
}
.table-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.table-toolbar h3 {
font-size: 1.15rem;
font-weight: 700;
}
.table-toolbar button {
padding: 0.72rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: 0.85rem;
background: #fff;
color: #304038;
font-weight: 600;
cursor: pointer;
}
.table-wrap {
overflow-x: auto;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: separate;
border-spacing: 0 0.75rem;
} }
th, th,
td { td {
padding: 1rem;
text-align: left; text-align: left;
padding: 0.85rem 0.4rem; white-space: nowrap;
border-bottom: 1px solid rgba(91, 69, 40, 0.1); }
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;
}
.product-cell {
min-width: 20rem;
}
.product-item,
.mix-block,
.number-block {
display: flex;
align-items: center;
gap: 0.8rem;
}
.product-item strong,
.mix-block strong,
.number-block strong {
display: block;
font-size: 0.96rem;
}
.product-item span:last-child,
.mix-block span,
.number-block span {
display: block;
margin-top: 0.18rem;
font-size: 0.82rem;
}
.product-badge {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.85rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.mix-block,
.number-block {
display: grid;
gap: 0.1rem;
}
.sale-pill,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.42rem 0.75rem;
font-size: 0.84rem;
font-weight: 600;
}
.sale-pill {
color: #245961;
background: var(--blue-soft);
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.warning {
color: #a9681d;
background: #fff6e6;
}
@media (max-width: 960px) {
.metric-row {
grid-template-columns: 1fr;
}
.table-toolbar {
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>
+20 -6
View File
@@ -1,10 +1,24 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load() {
const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]); if (!hasStoredClientSession()) {
return { return {
products, products: [],
productCosts productCosts: []
}; };
} }
try {
const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]);
return {
products,
productCosts
};
} catch {
return {
products: [],
productCosts: []
};
}
}
+435 -254
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { operatorSession } from '$lib/session'; import { clientSession } from '$lib/session';
import type { Mix, Product, ProductCostBreakdown } from '$lib/types'; import type { Mix, Product, ProductCostBreakdown, RawMaterial } from '$lib/types';
let { data } = $props(); let { data } = $props();
@@ -21,6 +21,18 @@
return `$${value.toFixed(digits)}`; return `$${value.toFixed(digits)}`;
} }
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No date';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(value));
}
function getImpactedMixes(materialId: number): Mix[] { function getImpactedMixes(materialId: number): Mix[] {
return data.mixes.filter((mix: Mix) => mix.ingredients.some((ingredient) => ingredient.raw_material_id === materialId)); return data.mixes.filter((mix: Mix) => mix.ingredients.some((ingredient) => ingredient.raw_material_id === materialId));
} }
@@ -107,28 +119,49 @@
pendingMaterialId = null; pendingMaterialId = null;
} }
} }
const totalSpend = $derived(
data.rawMaterials.reduce(
(sum: number, material: RawMaterial) => sum + (material.current_price?.market_value ?? 0),
0
)
);
const averageWaste = $derived(
data.rawMaterials.length
? data.rawMaterials.reduce(
(sum: number, material: RawMaterial) => sum + (material.current_price?.waste_percentage ?? 0),
0
) / data.rawMaterials.length
: 0
);
const latestEffectiveDate = $derived(
[...data.rawMaterials]
.map((material: RawMaterial) => material.current_price?.effective_date)
.filter(Boolean)
.sort()
.at(-1) ?? null
);
const activeMaterials = $derived(data.rawMaterials.filter((material: RawMaterial) => material.status === 'active'));
</script> </script>
{#if !$operatorSession} {#if !$clientSession}
<section class="locked-panel"> <section class="locked-card">
<p class="eyebrow">Operator access required</p> <p class="eyebrow">Client Access Required</p>
<h1>Sign in from the homepage before managing raw materials.</h1> <h2>Sign in on the Hunter Premium Produce home page before viewing raw material pricing.</h2>
<p>This page is the input maintenance area for Mix Master and downstream product pricing.</p> <p>This workflow updates source inputs and pushes new values through mix and product calculations.</p>
<a href="/">Return to login</a> <a href="/">Return to sign-in</a>
</section> </section>
{:else} {:else}
<section class="page-header"> <section class="page-intro">
<div> <div>
<p class="eyebrow">Raw material manager</p> <p class="eyebrow">Input Cost Control</p>
<h1>Maintain input costs and watch the costing model update downstream.</h1> <h2>Maintain raw materials with a cleaner operational workflow.</h2>
<p> <p>Update source pricing, track downstream exposure, and keep the costing engine current from one workspace.</p>
Every active price version feeds the existing mix calculation engine. Save a new price, then review the
impacted mixes and finished product outputs below.
</p>
</div> </div>
<div class="header-status">
<span>{$operatorSession.email}</span> <div class="intro-chip">
<strong>{data.rawMaterials.length} materials under control</strong> <span>{$clientSession.email}</span>
<strong>{activeMaterials.length} active materials</strong>
</div> </div>
</section> </section>
@@ -140,13 +173,34 @@
<p class="feedback error">{errorMessage}</p> <p class="feedback error">{errorMessage}</p>
{/if} {/if}
<section class="metric-row">
<article class="metric-card">
<span>Total Spend Tracked</span>
<strong>{currency(totalSpend)}</strong>
<p>Across current market values</p>
</article>
<article class="metric-card">
<span>Average Waste</span>
<strong>{(averageWaste * 100).toFixed(1)}%</strong>
<p>Current blended input loss</p>
</article>
<article class="metric-card">
<span>Latest Price Update</span>
<strong>{formatDate(latestEffectiveDate)}</strong>
<p>Most recent effective date on file</p>
</article>
</section>
<section class="top-grid"> <section class="top-grid">
<article class="surface-card"> <article class="surface-card form-card">
<div class="panel-heading"> <div class="section-heading">
<div> <div>
<p class="eyebrow">Add raw material</p> <p class="eyebrow">Create Input</p>
<h2>Create a new tracked input</h2> <h3>Add a new raw material</h3>
</div> </div>
<span class="soft-pill">Live costing source</span>
</div> </div>
<form class="material-form" onsubmit={handleCreateMaterial}> <form class="material-form" onsubmit={handleCreateMaterial}>
@@ -196,100 +250,116 @@
</label> </label>
</div> </div>
<label> <div class="form-grid single">
Material notes <label>
<textarea name="notes" rows="3"></textarea> Material notes
</label> <textarea name="notes" rows="3"></textarea>
</label>
<label> <label>
Price notes Price notes
<textarea name="price_notes" rows="2"></textarea> <textarea name="price_notes" rows="3"></textarea>
</label> </label>
</div>
<button type="submit" disabled={isCreating}> <button class="primary-button" type="submit" disabled={isCreating}>
{isCreating ? 'Creating material...' : 'Create raw material'} {isCreating ? 'Creating material...' : 'Create raw material'}
</button> </button>
</form> </form>
</article> </article>
<article class="surface-card"> <div class="summary-stack">
<div class="panel-heading"> <article class="surface-card">
<div> <div class="section-heading">
<p class="eyebrow">Downstream view</p> <div>
<h2>Current mix and product snapshot</h2> <p class="eyebrow">Downstream Snapshot</p>
<h3>Mixes affected by current inputs</h3>
</div>
</div> </div>
</div>
<div class="snapshot-list"> <div class="mini-list">
<article> {#each data.mixes as mix}
<h3>Mix Master</h3> <article>
<ul> <div>
{#each data.mixes as mix}
<li>
<strong>{mix.name}</strong> <strong>{mix.name}</strong>
<span>{currency(mix.mix_cost_per_kg, 4)} / kg</span> <span>{mix.client_name}</span>
</li> </div>
{/each} <strong>{currency(mix.mix_cost_per_kg, 4)} / kg</strong>
</ul> </article>
</article> {/each}
</div>
</article>
<article> <article class="surface-card">
<h3>Finished products</h3> <div class="section-heading">
<ul> <div>
{#each data.productCosts as row} <p class="eyebrow">Product Exposure</p>
<li> <h3>Finished outputs linked to live pricing</h3>
</div>
</div>
<div class="mini-list">
{#each data.productCosts as row}
<article>
<div>
<strong>{row.product_name}</strong> <strong>{row.product_name}</strong>
<span>{currency(row.finished_product_delivered)}</span> <span>{row.warnings.length ? 'Check warnings' : 'Stable pricing'}</span>
</li> </div>
{/each} <strong>{currency(row.finished_product_delivered)}</strong>
</ul> </article>
</article> {/each}
</div> </div>
</article> </article>
</div>
</section> </section>
<section class="material-list"> <section class="materials-list">
{#each data.rawMaterials as material} {#each data.rawMaterials as material}
{@const impactedMixes = getImpactedMixes(material.id)} {@const impactedMixes = getImpactedMixes(material.id)}
{@const impactedProducts = getImpactedProducts(material.id)} {@const impactedProducts = getImpactedProducts(material.id)}
<article class="surface-card material-card"> <article class="surface-card material-card">
<div class="panel-heading"> <div class="material-header">
<div> <div class="material-title">
<p class="eyebrow">Tracked input</p> <span class={`material-icon ${material.status === 'active' ? 'active' : 'muted'}`}>RM</span>
<h2>{material.name}</h2> <div>
<p class="subtle"> <h3>{material.name}</h3>
{material.supplier || 'Supplier not set'} · {material.unit_of_measure} · {material.kg_per_unit} kg per <p>{material.supplier || 'Supplier not set'} · {material.unit_of_measure} · {material.kg_per_unit} kg per unit</p>
unit </div>
</p>
</div> </div>
<span class:inactive={material.status !== 'active'} class="status-pill">{material.status}</span>
<span class={`status-pill ${material.status === 'active' ? 'positive' : 'neutral'}`}>{material.status}</span>
</div> </div>
<div class="material-grid"> <div class="material-grid">
<section class="detail-panel"> <section class="stats-grid">
<div class="detail-row"> <article>
<span>Current market value</span> <span>Market value</span>
<strong>{currency(material.current_price?.market_value)}</strong> <strong>{currency(material.current_price?.market_value)}</strong>
</div> </article>
<div class="detail-row"> <article>
<span>Current waste</span> <span>Waste</span>
<strong> <strong>
{material.current_price ? `${(material.current_price.waste_percentage * 100).toFixed(1)}%` : 'N/A'} {material.current_price ? `${(material.current_price.waste_percentage * 100).toFixed(1)}%` : 'N/A'}
</strong> </strong>
</div> </article>
<div class="detail-row"> <article>
<span>Cost per kg</span> <span>Cost per kg</span>
<strong>{currency(material.current_price?.cost_per_kg, 4)}</strong> <strong>{currency(material.current_price?.cost_per_kg, 4)}</strong>
</div> </article>
<div class="detail-row"> <article>
<span>Effective date</span> <span>Effective date</span>
<strong>{material.current_price?.effective_date ?? 'N/A'}</strong> <strong>{formatDate(material.current_price?.effective_date)}</strong>
</div> </article>
</section> </section>
<form class="price-form" onsubmit={(event) => handleAddPrice(event, material.id)}> <form class="price-card" onsubmit={(event) => handleAddPrice(event, material.id)}>
<h3>Record a new price version</h3> <div class="section-heading">
<div>
<p class="eyebrow">New Version</p>
<h4>Record a fresh price</h4>
</div>
</div>
<div class="form-grid compact"> <div class="form-grid compact">
<label> <label>
@@ -313,42 +383,56 @@
<textarea name="notes" rows="2"></textarea> <textarea name="notes" rows="2"></textarea>
</label> </label>
<button type="submit" disabled={pendingMaterialId === material.id}> <button class="primary-button" type="submit" disabled={pendingMaterialId === material.id}>
{pendingMaterialId === material.id ? 'Saving price...' : 'Save price version'} {pendingMaterialId === material.id ? 'Saving price...' : 'Save price version'}
</button> </button>
</form> </form>
</div> </div>
<div class="impact-grid"> <div class="impact-grid">
<section class="impact-panel"> <section class="impact-card">
<h3>Impacted mixes</h3> <div class="impact-heading">
<h4>Impacted mixes</h4>
<span>{impactedMixes.length}</span>
</div>
{#if impactedMixes.length} {#if impactedMixes.length}
<ul> <div class="impact-list">
{#each impactedMixes as mix} {#each impactedMixes as mix}
<li> <article>
<strong>{mix.name}</strong> <div>
<span>{currency(mix.mix_cost_per_kg, 4)} / kg</span> <strong>{mix.name}</strong>
</li> <span>{mix.client_name}</span>
</div>
<strong>{currency(mix.mix_cost_per_kg, 4)} / kg</strong>
</article>
{/each} {/each}
</ul> </div>
{:else} {:else}
<p class="empty">No mix currently references this material.</p> <p class="empty">No active mix currently references this material.</p>
{/if} {/if}
</section> </section>
<section class="impact-panel"> <section class="impact-card">
<h3>Impacted products</h3> <div class="impact-heading">
<h4>Impacted products</h4>
<span>{impactedProducts.length}</span>
</div>
{#if impactedProducts.length} {#if impactedProducts.length}
<ul> <div class="impact-list">
{#each impactedProducts as product} {#each impactedProducts as product}
<li> <article>
<strong>{product.name}</strong> <div>
<span>{currency(product.deliveredCost?.finished_product_delivered)}</span> <strong>{product.name}</strong>
</li> <span>{product.mix_name}</span>
</div>
<strong>{currency(product.deliveredCost?.finished_product_delivered)}</strong>
</article>
{/each} {/each}
</ul> </div>
{:else} {:else}
<p class="empty">No product currently depends on this material.</p> <p class="empty">No finished product currently depends on this material.</p>
{/if} {/if}
</section> </section>
</div> </div>
@@ -358,96 +442,115 @@
{/if} {/if}
<style> <style>
h1,
h2, h2,
h3, h3,
h4,
p { p {
margin: 0; margin: 0;
} }
a {
color: var(--brand);
text-decoration: none;
}
.eyebrow { .eyebrow {
color: var(--muted); color: #7f8e85;
font-size: 0.78rem; font-size: 0.78rem;
letter-spacing: 0.1em; font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
} }
.locked-panel, .locked-card,
.page-header, .page-intro,
.surface-card, .feedback,
.feedback { .metric-card,
background: rgba(255, 250, 241, 0.82); .surface-card {
background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1.5rem; border-radius: 1.35rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.locked-panel, .locked-card,
.page-header, .page-intro,
.surface-card { .feedback,
padding: 1.5rem; .metric-row,
.top-grid,
.materials-list {
margin-bottom: 1.25rem;
} }
.locked-panel { .locked-card,
.page-intro,
.surface-card {
padding: 1.2rem;
}
.locked-card {
display: grid; display: grid;
gap: 0.75rem; gap: 0.7rem;
max-width: 42rem; max-width: 42rem;
} }
.page-header { .locked-card h2,
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
font-size: clamp(1.7rem, 3vw, 2.25rem);
font-weight: 700;
}
.locked-card p:last-of-type,
.page-intro p:last-child,
.metric-card p,
.intro-chip span,
.mini-list span,
.material-title p,
.stats-grid span,
.impact-list span,
.empty {
color: var(--muted);
}
.locked-card a {
color: var(--green-deep);
font-weight: 600;
}
.page-intro {
display: flex; display: flex;
align-items: end; align-items: end;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem;
} }
.page-header p:last-child, .intro-chip {
.subtle, display: grid;
.empty, gap: 0.25rem;
.detail-row span, padding: 0.95rem 1rem;
.snapshot-list li span { border: 1px solid var(--line);
color: var(--muted); border-radius: 1rem;
background: var(--panel-soft);
} }
.page-header h1 { .intro-chip strong {
margin: 0.35rem 0 0.55rem; font-size: 1rem;
font-size: clamp(1.8rem, 4vw, 3rem);
max-width: 16ch;
line-height: 1;
}
.header-status {
text-align: right;
}
.header-status span {
display: block;
color: var(--muted);
margin-bottom: 0.35rem;
} }
.feedback { .feedback {
padding: 0.95rem 1.1rem; padding: 0.95rem 1rem;
margin: 0 0 1rem;
font-weight: 600; font-weight: 600;
} }
.feedback.success { .feedback.success {
border-color: rgba(44, 106, 66, 0.2); color: var(--green-deep);
color: #245838; border-color: #d8ecdf;
background: #f6fcf8;
} }
.feedback.error { .feedback.error {
border-color: rgba(163, 48, 29, 0.22); color: #a03737;
color: #8d2b1f; border-color: #f0d9d9;
background: #fff8f8;
} }
.metric-row,
.top-grid, .top-grid,
.material-grid, .material-grid,
.impact-grid { .impact-grid {
@@ -455,21 +558,69 @@
gap: 1rem; gap: 1rem;
} }
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.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;
font-weight: 700;
}
.top-grid { .top-grid {
grid-template-columns: 1.2fr 0.8fr; grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.85fr);
}
.summary-stack {
display: grid;
gap: 1rem;
}
.section-heading,
.material-header,
.impact-heading,
.mini-list article,
.impact-list article {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.section-heading {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.panel-heading { .section-heading h3,
display: flex; .material-header h3,
align-items: start; .impact-heading h4 {
justify-content: space-between; font-size: 1.12rem;
gap: 1rem; font-weight: 700;
margin-bottom: 1rem; }
.soft-pill {
padding: 0.48rem 0.8rem;
border-radius: 999px;
color: var(--green-deep);
background: var(--green-soft);
font-size: 0.86rem;
font-weight: 600;
} }
.material-form, .material-form,
.price-form { .price-card {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
@@ -477,7 +628,11 @@
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem; gap: 0.85rem;
}
.form-grid.single {
grid-template-columns: 1fr;
} }
.form-grid.compact { .form-grid.compact {
@@ -487,147 +642,173 @@
label { label {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
color: #53645b;
font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--muted);
}
input,
textarea,
select,
button {
font: inherit;
} }
input, input,
textarea, textarea,
select { select {
width: 100%; width: 100%;
padding: 0.85rem 0.95rem; padding: 0.9rem 0.95rem;
border-radius: 1rem; border: 1px solid var(--line-strong);
border: 1px solid rgba(90, 45, 24, 0.16); border-radius: 0.95rem;
background: rgba(255, 255, 255, 0.85); background: var(--panel-soft);
color: var(--text);
} }
button { .primary-button {
padding: 0.95rem 1.1rem;
border: none;
border-radius: 1rem;
background: linear-gradient(135deg, var(--brand-deep), var(--brand));
color: #fff7ef;
font-weight: 700;
cursor: pointer;
justify-self: start; justify-self: start;
padding: 0.85rem 1rem;
border: none;
border-radius: 0.9rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
font-weight: 600;
cursor: pointer;
} }
button:disabled { .primary-button:disabled {
opacity: 0.7; opacity: 0.7;
cursor: wait; cursor: wait;
} }
.snapshot-list { .mini-list,
display: grid; .impact-list,
gap: 1rem; .materials-list {
}
.snapshot-list article {
padding: 1rem;
border-radius: 1rem;
background: rgba(143, 79, 31, 0.06);
}
.snapshot-list ul,
.impact-panel ul {
list-style: none;
padding: 0;
margin: 0.9rem 0 0;
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
} }
.snapshot-list li, .mini-list article,
.impact-panel li { .impact-list article {
display: flex; padding: 0.95rem 1rem;
align-items: center; border: 1px solid var(--line);
justify-content: space-between; border-radius: 1rem;
gap: 1rem; background: var(--panel-soft);
}
.material-list {
display: grid;
gap: 1rem;
} }
.material-card { .material-card {
display: grid; display: grid;
gap: 1rem; gap: 1.1rem;
} }
.status-pill { .material-title {
padding: 0.45rem 0.8rem; display: flex;
border-radius: 999px; align-items: center;
background: rgba(44, 106, 66, 0.12);
color: #245838;
font-weight: 700;
text-transform: capitalize;
}
.status-pill.inactive {
background: rgba(143, 79, 31, 0.1);
color: var(--brand-deep);
}
.material-grid {
grid-template-columns: 0.75fr 1.25fr;
}
.detail-panel,
.impact-panel {
padding: 1rem;
border-radius: 1rem;
background: rgba(143, 79, 31, 0.06);
}
.detail-panel {
display: grid;
gap: 0.85rem; gap: 0.85rem;
} }
.detail-row { .material-icon {
display: flex; width: 2.4rem;
height: 2.4rem;
display: inline-flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 1rem; border-radius: 0.85rem;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.material-icon.active {
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
}
.material-icon.muted {
color: #55685f;
background: #e9efeb;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
text-transform: capitalize;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.material-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.stats-grid article,
.price-card,
.impact-card {
padding: 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.stats-grid strong {
display: block;
margin-top: 0.35rem;
font-size: 1.1rem;
font-weight: 700;
} }
.impact-grid { .impact-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@media (max-width: 1100px) { .impact-heading {
margin-bottom: 0.9rem;
}
.impact-heading span {
color: var(--muted);
font-size: 0.92rem;
font-weight: 600;
}
@media (max-width: 1180px) {
.metric-row,
.top-grid, .top-grid,
.material-grid, .material-grid,
.impact-grid { .impact-grid,
.stats-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 760px) { @media (max-width: 820px) {
.page-header, .page-intro,
.panel-heading, .section-heading,
.snapshot-list li, .material-header,
.impact-panel li, .impact-heading,
.detail-row { .mini-list article,
.impact-list article {
flex-direction: column; flex-direction: column;
align-items: start; align-items: flex-start;
} }
.form-grid, .form-grid,
.form-grid.compact { .form-grid.compact {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.header-status {
text-align: left;
}
} }
</style> </style>
+31 -12
View File
@@ -1,17 +1,36 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load() {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([ if (!hasStoredClientSession()) {
api.rawMaterials(), return {
api.mixes(), rawMaterials: [],
api.products(), mixes: [],
api.productCosts() products: [],
]); productCosts: []
};
}
return { try {
rawMaterials, const [rawMaterials, mixes, products, productCosts] = await Promise.all([
mixes, api.rawMaterials(),
products, api.mixes(),
productCosts api.products(),
}; api.productCosts()
]);
return {
rawMaterials,
mixes,
products,
productCosts
};
} catch {
return {
rawMaterials: [],
mixes: [],
products: [],
productCosts: []
};
}
} }
+263 -40
View File
@@ -1,65 +1,288 @@
<script lang="ts"> <script lang="ts">
let { data } = $props(); let { data } = $props();
const scenarioRows = $derived(
data.scenarios.map((scenario) => ({
...scenario,
overrideKeys: Object.keys(scenario.overrides ?? {})
}))
);
const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
</script> </script>
<section class="panel"> <section class="page-intro">
<h2>Scenarios</h2> <div>
<p>Simulation workspaces for raw cost, freight, process, and margin changes.</p> <p class="eyebrow">Scenario Sandbox</p>
<h2>Simulation workspaces with a cleaner review and comparison layer.</h2>
<div class="scenario-list"> <p>Scenarios now read like structured operating plans instead of raw debug output.</p>
{#each data.scenarios as scenario}
<article>
<header>
<div>
<h3>{scenario.name}</h3>
<p>{scenario.description ?? 'No description'}</p>
</div>
<span>{scenario.status}</span>
</header>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
</article>
{/each}
</div> </div>
</section> </section>
<section class="metric-row">
<article class="metric-card">
<span>Total Scenarios</span>
<strong>{scenarioRows.length}</strong>
<p>Saved planning workspaces</p>
</article>
<article class="metric-card">
<span>Approved</span>
<strong>{approvedCount}</strong>
<p>Ready for pricing reference</p>
</article>
<article class="metric-card">
<span>Overrides In Use</span>
<strong>{scenarioRows.reduce((sum, row) => sum + row.overrideKeys.length, 0)}</strong>
<p>Total override keys across all scenarios</p>
</article>
</section>
<section class="scenario-list">
{#each scenarioRows as scenario}
<article class="surface-card">
<div class="scenario-header">
<div>
<p class="eyebrow">Scenario</p>
<h3>{scenario.name}</h3>
<p>{scenario.description ?? 'No description provided yet.'}</p>
</div>
<span class={`status-pill ${scenario.status === 'approved' ? 'positive' : 'neutral'}`}>{scenario.status}</span>
</div>
<div class="scenario-grid">
<section class="detail-card">
<div class="detail-row">
<span>Override count</span>
<strong>{scenario.overrideKeys.length}</strong>
</div>
<div class="detail-row">
<span>Primary state</span>
<strong>{scenario.status}</strong>
</div>
</section>
<section class="detail-card">
<p class="eyebrow">Override Keys</p>
{#if scenario.overrideKeys.length}
<div class="chip-list">
{#each scenario.overrideKeys as key}
<span>{key}</span>
{/each}
</div>
{:else}
<p class="empty">No overrides have been defined yet.</p>
{/if}
</section>
</div>
<section class="json-card">
<div class="json-header">
<h4>Scenario payload</h4>
<span>JSON view</span>
</div>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
</section>
</article>
{/each}
</section>
<style> <style>
.panel { h2,
display: flex; h3,
flex-direction: column; h4,
p,
pre {
margin: 0;
}
.eyebrow {
color: #7f8e85;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.scenario-list {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.scenario-header p:last-child,
.empty,
.json-header span,
pre {
color: var(--muted);
}
.metric-row,
.scenario-grid {
display: grid;
gap: 1rem; gap: 1rem;
} }
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card,
.surface-card,
.detail-card,
.json-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.35rem;
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;
font-weight: 700;
}
.scenario-list { .scenario-list {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
article { .surface-card {
background: rgba(255, 251, 244, 0.82); padding: 1.2rem;
border-radius: 1.2rem; display: grid;
padding: 1.1rem;
border: 1px solid rgba(91, 69, 40, 0.12);
}
header {
display: flex;
justify-content: space-between;
gap: 1rem; gap: 1rem;
align-items: start;
} }
h2, .scenario-header,
h3, .detail-row,
p, .json-header {
pre { display: flex;
margin: 0; align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.scenario-header h3 {
margin: 0.3rem 0 0.4rem;
font-size: 1.15rem;
font-weight: 700;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
text-transform: capitalize;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.scenario-grid {
grid-template-columns: 0.7fr 1.3fr;
}
.detail-card,
.json-card {
padding: 1rem;
}
.detail-row + .detail-row {
margin-top: 0.9rem;
}
.detail-row span {
color: var(--muted);
}
.detail-row strong {
font-size: 1rem;
font-weight: 700;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.7rem;
}
.chip-list span {
padding: 0.45rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: #365044;
font-size: 0.84rem;
font-weight: 600;
}
.json-header {
margin-bottom: 0.8rem;
}
.json-header h4 {
font-size: 1rem;
font-weight: 700;
} }
pre { pre {
margin-top: 0.75rem; padding: 1rem;
padding: 0.85rem; border-radius: 1rem;
border-radius: 0.9rem; background: var(--panel-soft);
background: rgba(53, 42, 29, 0.08); border: 1px solid var(--line);
overflow: auto; overflow: auto;
font-size: 0.85rem;
line-height: 1.55;
}
@media (max-width: 960px) {
.metric-row,
.scenario-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.scenario-header,
.detail-row,
.json-header {
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>
+16 -4
View File
@@ -1,8 +1,20 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load() {
return { if (!hasStoredClientSession()) {
scenarios: await api.scenarios() return {
}; scenarios: []
} };
}
try {
return {
scenarios: await api.scenarios()
};
} catch {
return {
scenarios: []
};
}
}