diff --git a/DESIGN.MD b/DESIGN.MD new file mode 100644 index 0000000..35facbb --- /dev/null +++ b/DESIGN.MD @@ -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. diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index aca4201..bd7e971 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,7 +1,13 @@ -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status 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.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"]) @@ -11,18 +17,49 @@ class LoginRequest(BaseModel): password: str -class LoginResponse(BaseModel): +class SessionResponse(BaseModel): name: str email: str role: str + tenant_id: str | None = None + token: str -@router.post("/login", response_model=LoginResponse) -def login(payload: LoginRequest): - if payload.email.strip().lower() != settings.operator_email.lower() or payload.password != settings.operator_password: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") - return { - "name": settings.operator_name, - "email": settings.operator_email, - "role": "operator", - } +def _build_session_response(*, name: str, email: str, role: str, tenant_id: str | None = None) -> SessionResponse: + token = issue_token({"name": name, "email": email, "role": role, "tenant_id": tenant_id}) + return SessionResponse(name=name, email=email, role=role, tenant_id=tenant_id, token=token) + + +@router.post("/client/login", response_model=SessionResponse) +def client_login(payload: LoginRequest, db: Session = Depends(get_db)): + 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) diff --git a/backend/app/api/client_access.py b/backend/app/api/client_access.py new file mode 100644 index 0000000..f87fa5d --- /dev/null +++ b/backend/app/api/client_access.py @@ -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) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..b38e3e4 --- /dev/null +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/mixes.py b/backend/app/api/mixes.py index aa1f32a..b7f77f6 100644 --- a/backend/app/api/mixes.py +++ b/backend/app/api/mixes.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session +from app.api.deps import AuthSession, require_client_session from app.db.session import get_db from app.models.mix import Mix, MixIngredient from app.models.raw_material import RawMaterial @@ -12,14 +13,15 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"]) @router.get("", response_model=list[MixRead]) -def list_mixes(db: Session = Depends(get_db)): - mixes = db.scalars(select(Mix).order_by(Mix.name)).all() +def list_mixes(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + 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] @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( + tenant_id=session.tenant_id, client_name=payload.client_name, name=payload.name, status=payload.status, @@ -29,10 +31,16 @@ def create_mix(payload: MixCreate, db: Session = Depends(get_db)): db.add(mix) db.flush() 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") db.add( MixIngredient( + tenant_id=session.tenant_id, mix_id=mix.id, raw_material_id=ingredient.raw_material_id, 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) -def get_mix(mix_id: int, db: Session = Depends(get_db)): - if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: +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, Mix.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Mix not found") return calculate_mix_cost(db, mix_id) @router.patch("/{mix_id}", response_model=MixRead) -def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)): - mix = db.scalar(select(Mix).where(Mix.id == mix_id)) +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.tenant_id == session.tenant_id)) if mix is None: raise HTTPException(status_code=404, detail="Mix not found") for field, value in payload.model_dump(exclude_unset=True).items(): @@ -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) -def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, db: Session = Depends(get_db)): - if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: +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, Mix.tenant_id == session.tenant_id)) is None: 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") - 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() return calculate_mix_cost(db, mix_id) @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)): - ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id)) +def update_mix_ingredient( + 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: raise HTTPException(status_code=404, detail="Ingredient not found") 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) -def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends(get_db)): - ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id)) +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, + MixIngredient.tenant_id == session.tenant_id, + ) + ) if ingredient is None: raise HTTPException(status_code=404, detail="Ingredient not found") 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) -def get_mix_cost_breakdown(mix_id: int, db: Session = Depends(get_db)): - if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: +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, Mix.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Mix not found") return calculate_mix_cost(db, mix_id) - diff --git a/backend/app/api/powerbi.py b/backend/app/api/powerbi.py index 07c30b7..89e9815 100644 --- a/backend/app/api/powerbi.py +++ b/backend/app/api/powerbi.py @@ -2,37 +2,39 @@ from fastapi import APIRouter, Depends from sqlalchemy import select 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.models.mix import Mix from app.models.product import Product from app.models.raw_material import RawMaterial 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 router = APIRouter(prefix="/api/powerbi", tags=["powerbi"]) @router.get("/raw-material-costs") -def raw_material_costs(db: Session = Depends(get_db)): - materials = db.scalars(select(RawMaterial).order_by(RawMaterial.name)).all() +def raw_material_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + 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] @router.get("/mix-costs") -def mix_costs(db: Session = Depends(get_db)): - mixes = db.scalars(select(Mix).order_by(Mix.name)).all() +def mix_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + 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] @router.get("/product-costs") -def product_costs(db: Session = Depends(get_db)): - products = db.scalars(select(Product).order_by(Product.name)).all() +def product_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + 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] @router.get("/scenario-results") -def scenario_results(db: Session = Depends(get_db)): - scenarios = db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all() +def scenario_results(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + scenarios = db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all() return [ { "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") -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] = [] - 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) for warning in result["warnings"]: 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) for warning in result["warnings"]: 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) if serialized["current_price"] is None: issues.append({"entity_type": "raw_material", "entity_id": material.id, "entity_name": material.name, "warning": "No active price"}) return issues - diff --git a/backend/app/api/products.py b/backend/app/api/products.py index e9e87bb..75058db 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session +from app.api.deps import AuthSession, require_client_session from app.db.session import get_db from app.models.mix import Mix from app.models.product import Product @@ -33,16 +34,16 @@ def _serialize_product(product: Product) -> dict: @router.get("", response_model=list[ProductRead]) -def list_products(db: Session = Depends(get_db)): - products = db.scalars(select(Product).order_by(Product.name)).all() +def list_products(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + 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] @router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED) -def create_product(payload: ProductCreate, db: Session = Depends(get_db)): - if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None: +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, Mix.tenant_id == session.tenant_id)) is None: 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.commit() db.refresh(product) @@ -50,19 +51,19 @@ def create_product(payload: ProductCreate, db: Session = Depends(get_db)): @router.get("/{product_id}", response_model=ProductRead) -def get_product(product_id: int, db: Session = Depends(get_db)): - product = db.scalar(select(Product).where(Product.id == product_id)) +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.tenant_id == session.tenant_id)) if product is None: raise HTTPException(status_code=404, detail="Product not found") return _serialize_product(product) @router.patch("/{product_id}", response_model=ProductRead) -def update_product(product_id: int, payload: ProductUpdate, db: Session = Depends(get_db)): - product = db.scalar(select(Product).where(Product.id == product_id)) +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.tenant_id == session.tenant_id)) if product is None: 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") for field, value in payload.model_dump(exclude_unset=True).items(): 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) -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: return calculate_product_cost(db, product_id) 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) -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: return calculate_product_cost(db, product_id) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc - diff --git a/backend/app/api/raw_materials.py b/backend/app/api/raw_materials.py index cf1514b..0cb3bfb 100644 --- a/backend/app/api/raw_materials.py +++ b/backend/app/api/raw_materials.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload +from app.api.deps import AuthSession, require_client_session from app.db.session import get_db from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.schemas.raw_material import ( @@ -33,14 +34,20 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d @router.get("", response_model=list[RawMaterialRead]) -def list_raw_materials(db: Session = Depends(get_db)): - materials = db.scalars(select(RawMaterial).options(selectinload(RawMaterial.price_versions)).order_by(RawMaterial.name)).all() +def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + 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] @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( + tenant_id=session.tenant_id, name=payload.name, supplier=payload.supplier, 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( RawMaterialPriceVersion( + tenant_id=session.tenant_id, market_value=payload.initial_price.market_value, waste_percentage=payload.initial_price.waste_percentage, 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) -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( - 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: 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) -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( - 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: 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) -def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCreate, db: Session = Depends(get_db)): - material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id)) +def add_price_version( + 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: raise HTTPException(status_code=404, detail="Raw material not found") price = RawMaterialPriceVersion( + tenant_id=session.tenant_id, raw_material_id=raw_material_id, market_value=payload.market_value, 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]) -def get_price_history(raw_material_id: int, db: Session = Depends(get_db)): - material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id)) +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, RawMaterial.tenant_id == session.tenant_id)) if material is None: raise HTTPException(status_code=404, detail="Raw material not found") prices = db.scalars( 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()) ).all() items = [] diff --git a/backend/app/api/scenarios.py b/backend/app/api/scenarios.py index 4249eaa..f82e75e 100644 --- a/backend/app/api/scenarios.py +++ b/backend/app/api/scenarios.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session +from app.api.deps import AuthSession, require_client_session from app.db.session import get_db from app.models.scenario import CostingResult, Scenario 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]) -def list_scenarios(db: Session = Depends(get_db)): - return db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all() +def list_scenarios(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + 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) -def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)): - scenario = Scenario(name=payload.name, description=payload.description, overrides=payload.overrides) +def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + scenario = Scenario(tenant_id=session.tenant_id, name=payload.name, description=payload.description, overrides=payload.overrides) db.add(scenario) db.commit() db.refresh(scenario) @@ -25,16 +26,16 @@ def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)): @router.get("/{scenario_id}", response_model=ScenarioRead) -def get_scenario(scenario_id: int, db: Session = Depends(get_db)): - scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) +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.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") return scenario @router.post("/{scenario_id}/run", response_model=ScenarioRunResponse) -def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)): - scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) +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.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") 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") -def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)): - scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) +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.tenant_id == session.tenant_id)) if scenario is None: 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 [ { "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) -def approve_scenario(scenario_id: int, db: Session = Depends(get_db)): - scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) +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.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") 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) -def reject_scenario(scenario_id: int, db: Session = Depends(get_db)): - scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) +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.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") scenario.status = "rejected" db.commit() db.refresh(scenario) return scenario - diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e38d5c0..0a1dc4a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,18 +6,28 @@ from dataclasses import dataclass class Settings: app_name: str database_url: str - operator_name: str - operator_email: str - operator_password: str + client_name: str + client_email: str + client_password: str + client_tenant_id: str + admin_name: str + admin_email: str + admin_password: str + auth_secret: str @classmethod def from_env(cls) -> "Settings": return cls( app_name=os.getenv("APP_NAME", "Data Entry App API"), database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"), - operator_name=os.getenv("OPERATOR_NAME", "Operations Manager"), - operator_email=os.getenv("OPERATOR_EMAIL", "operator@example.com"), - operator_password=os.getenv("OPERATOR_PASSWORD", "changeme"), + client_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"), + client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"), + 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"), ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..a176075 --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py new file mode 100644 index 0000000..2948d0d --- /dev/null +++ b/backend/app/db/migrations.py @@ -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}) diff --git a/backend/app/main.py b/backend/app/main.py index 1ee1b01..43801ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware import uvicorn 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.powerbi import router as powerbi_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.core.config import settings 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 @asynccontextmanager async def lifespan(_: FastAPI): Base.metadata.create_all(bind=engine) + ensure_tenant_columns(engine) seed_if_empty() + sync_tenant_ids(engine) yield @@ -32,13 +36,14 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=["http://localhost:5173", "http://localhost:5174"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(auth_router) +app.include_router(client_access_router) app.include_router(raw_materials_router) app.include_router(mixes_router) app.include_router(products_router) @@ -58,11 +63,13 @@ def root(): "Confirm finished product pricing outputs", ], "endpoints": { - "login": "/api/auth/login", + "client_login": "/api/auth/client/login", + "admin_login": "/api/auth/admin/login", "raw_materials": "/api/raw-materials", "mixes": "/api/mixes", "products": "/api/products", "scenarios": "/api/scenarios", + "client_access": "/api/client-access", "docs": "/docs", }, } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 03ac0a3..b7ec4d1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,10 +1,14 @@ 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.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.scenario import CostingResult, Scenario __all__ = [ + "ClientAccount", + "ClientFeatureAccess", + "ClientUser", "CostingResult", "FreightCostRule", "Mix", @@ -16,4 +20,3 @@ __all__ = [ "RawMaterialPriceVersion", "Scenario", ] - diff --git a/backend/app/models/assumption.py b/backend/app/models/assumption.py index 6ed54de..7122a8a 100644 --- a/backend/app/models/assumption.py +++ b/backend/app/models/assumption.py @@ -10,6 +10,7 @@ class ProcessCostRule(Base): __tablename__ = "process_cost_rules" 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) grading_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" 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)) unit_of_measure: Mapped[str] = mapped_column(String(64)) own_bag: Mapped[bool] = mapped_column(Boolean, default=False) @@ -32,6 +34,7 @@ class FreightCostRule(Base): __tablename__ = "freight_cost_rules" 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)) unit_of_measure: Mapped[str] = mapped_column(String(64)) cost_per_unit: Mapped[float] = mapped_column(Float, default=0.0) diff --git a/backend/app/models/client_access.py b/backend/app/models/client_access.py new file mode 100644 index 0000000..259ba22 --- /dev/null +++ b/backend/app/models/client_access.py @@ -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") diff --git a/backend/app/models/mix.py b/backend/app/models/mix.py index accb338..b04cf17 100644 --- a/backend/app/models/mix.py +++ b/backend/app/models/mix.py @@ -32,6 +32,7 @@ class MixIngredient(Base): __table_args__ = (UniqueConstraint("mix_id", "raw_material_id", name="uq_mix_ingredient"),) 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) raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True) 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.raw_material import RawMaterial # noqa: E402 - diff --git a/backend/app/models/raw_material.py b/backend/app/models/raw_material.py index 6ea44b8..1d0f0bc 100644 --- a/backend/app/models/raw_material.py +++ b/backend/app/models/raw_material.py @@ -32,6 +32,7 @@ class RawMaterialPriceVersion(Base): __tablename__ = "raw_material_price_versions" 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) market_value: Mapped[float] = mapped_column(Float) 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) raw_material: Mapped[RawMaterial] = relationship(back_populates="price_versions") - diff --git a/backend/app/models/scenario.py b/backend/app/models/scenario.py index 4b7c0cd..668ae5f 100644 --- a/backend/app/models/scenario.py +++ b/backend/app/models/scenario.py @@ -12,6 +12,7 @@ class Scenario(Base): __tablename__ = "scenarios" 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) status: Mapped[str] = mapped_column(String(32), default="draft") description: Mapped[str | None] = mapped_column(String(500), nullable=True) @@ -28,6 +29,7 @@ class CostingResult(Base): __tablename__ = "costing_results" 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) product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True) 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) scenario: Mapped[Scenario] = relationship(back_populates="results") - diff --git a/backend/app/schemas/client_access.py b/backend/app/schemas/client_access.py new file mode 100644 index 0000000..bd88c27 --- /dev/null +++ b/backend/app/schemas/client_access.py @@ -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 diff --git a/backend/app/seed.py b/backend/app/seed.py index 40fbd3e..65097d7 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -1,76 +1,174 @@ -from datetime import date +from datetime import date, datetime from sqlalchemy import select from app.db.session import Base, SessionLocal, engine 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.product import Product 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(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: - existing = db.scalar(select(RawMaterial.id)) - if existing is not None: - 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", - ) - ) + seed_costing_workspace(db) + seed_client_access(db) db.commit() if __name__ == "__main__": seed_if_empty() - diff --git a/backend/app/services/client_access_service.py b/backend/app/services/client_access_service.py new file mode 100644 index 0000000..d7a2d37 --- /dev/null +++ b/backend/app/services/client_access_service.py @@ -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, + } diff --git a/backend/app/services/costing_engine.py b/backend/app/services/costing_engine.py index 25778bd..56e0374 100644 --- a/backend/app/services/costing_engine.py +++ b/backend/app/services/costing_engine.py @@ -123,7 +123,11 @@ def _get_process_costs(db: Session, process_name: str | None, overrides: dict) - if not process_name: 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: 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: return 0.0, [] - rule = db.scalar( - select(PackagingCostRule).where( - PackagingCostRule.sale_type == product.sale_type, - PackagingCostRule.unit_of_measure == product.unit_of_measure, - PackagingCostRule.own_bag == product.own_bag, - ) + query = select(PackagingCostRule).where( + PackagingCostRule.sale_type == product.sale_type, + PackagingCostRule.unit_of_measure == product.unit_of_measure, + 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: 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]]: - rule = db.scalar( - select(FreightCostRule).where( - FreightCostRule.sale_type == product.sale_type, - FreightCostRule.unit_of_measure == product.unit_of_measure, - ) + query = select(FreightCostRule).where( + FreightCostRule.sale_type == product.sale_type, + 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: return 0.0, ["Freight rule not found"] 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: 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))) if product is None: 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) warnings = list(mix_result["warnings"]) diff --git a/backend/app/services/scenario_engine.py b/backend/app/services/scenario_engine.py index eff4476..006a086 100644 --- a/backend/app/services/scenario_engine.py +++ b/backend/app/services/scenario_engine.py @@ -8,13 +8,14 @@ from app.services.costing_engine import calculate_product_cost def run_scenario(db: Session, scenario: Scenario) -> list[dict]: 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] = [] for product in products: breakdown = calculate_product_cost(db, product.id, overrides=scenario.overrides or {}) db.add( CostingResult( + tenant_id=scenario.tenant_id, scenario_id=scenario.id, product_id=product.id, finished_product_delivered=breakdown["finished_product_delivered"], @@ -29,4 +30,3 @@ def run_scenario(db: Session, scenario: Scenario) -> list[dict]: scenario.status = "reviewed" db.commit() return results - diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index 74182d6..6a601ef 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -8,9 +8,11 @@ from app.core.config import settings from app.db.session import Base from app.main import app 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.product import Product 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 @@ -85,11 +87,97 @@ def test_root_and_login_endpoints(): root_response = client.get("/") 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( - "/api/auth/login", - json={"email": settings.operator_email, "password": settings.operator_password}, + client_login_response = client.post( + "/api/auth/client/login", + json={"email": settings.client_email, "password": settings.client_password}, ) - assert login_response.status_code == 200 - assert login_response.json()["email"] == settings.operator_email + assert client_login_response.status_code == 200 + 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() diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ee8a614..4cea178 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,24 @@ 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 { + ClientAccessAccount, + ClientAccessPowerBiExport, + ClientUserCreateInput, + ClientUserUpdateInput, LoginResponse, + Mix, + MixCreateInput, + MixIngredientUpdateInput, + MixUpdateInput, Product, ProductCostBreakdown, RawMaterial, @@ -9,25 +26,55 @@ import type { RawMaterialPriceCreateInput, Scenario } from '$lib/types'; +import { getStoredAdminSession, getStoredClientSession } from '$lib/session'; const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000'; -async function fetchJson(path: string, fallback: T): Promise { +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(path: string, fallback: T, auth: AuthMode = 'none'): Promise { 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 (auth !== 'none') { + throw new Error(response.statusText || 'Unauthorized'); + } return fallback; } return (await response.json()) as T; - } catch { + } catch (error) { + if (auth !== 'none') { + throw error; + } return fallback; } } -async function request(path: string, options: RequestInit): Promise { +async function request(path: string, options: RequestInit, auth: AuthMode = 'none'): Promise { + const token = getToken(auth); const response = await fetch(`${API_BASE_URL}${path}`, { headers: { 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.headers ?? {}) }, ...options @@ -50,25 +97,77 @@ async function request(path: string, options: RequestInit): Promise { } export const api = { - rawMaterials: () => fetchJson('/api/raw-materials', mockRawMaterials), - mixes: () => fetchJson('/api/mixes', mockMixes), - products: () => fetchJson('/api/products', mockProducts), - productCosts: () => fetchJson('/api/powerbi/product-costs', mockCosts), - scenarios: () => fetchJson('/api/scenarios', mockScenarios), - dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', []), - login: (email: string, password: string) => - request('/api/auth/login', { + rawMaterials: () => fetchJson('/api/raw-materials', mockRawMaterials, 'client'), + mixes: () => fetchJson('/api/mixes', mockMixes, 'client'), + mix: (mixId: number) => request(`/api/mixes/${mixId}`, { method: 'GET' }, 'client'), + products: () => fetchJson('/api/products', mockProducts, 'client'), + productCosts: () => fetchJson('/api/powerbi/product-costs', mockCosts, 'client'), + scenarios: () => fetchJson('/api/scenarios', mockScenarios, 'client'), + clientAccess: () => fetchJson('/api/client-access', mockClientAccess, 'admin'), + clientAccessExport: () => fetchJson('/api/powerbi/client-access', mockClientAccessExport, 'admin'), + dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', [], 'client'), + clientLogin: (email: string, password: string) => + request('/api/auth/client/login', { method: 'POST', body: JSON.stringify({ email, password }) }), + adminLogin: (email: string, password: string) => + request('/api/auth/admin/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }), + login: (email: string, password: string) => + request('/api/auth/client/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }), + createMix: (payload: MixCreateInput) => + request('/api/mixes', { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateMix: (mixId: number, payload: MixUpdateInput) => + request(`/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(`/api/mixes/${mixId}/ingredients`, { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) => + request(`/api/mixes/${mixId}/ingredients/${ingredientId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + deleteMixIngredient: (mixId: number, ingredientId: number) => + request(`/api/mixes/${mixId}/ingredients/${ingredientId}`, { + method: 'DELETE' + }, 'client'), createRawMaterial: (payload: RawMaterialCreateInput) => request('/api/raw-materials', { method: 'POST', body: JSON.stringify(payload) - }), + }, 'client'), addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) => request(`/api/raw-materials/${rawMaterialId}/prices`, { method: 'POST', body: JSON.stringify(payload) - }) + }, 'client'), + createClientUser: (payload: ClientUserCreateInput) => + request('/api/client-access/users', { + method: 'POST', + body: JSON.stringify(payload) + }, 'admin'), + updateClientUser: (userId: number, payload: ClientUserUpdateInput) => + request(`/api/client-access/users/${userId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'admin'), + updateClientFeature: (featureId: number, payload: { enabled: boolean }) => + request(`/api/client-access/features/${featureId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'admin') }; diff --git a/frontend/src/lib/components/AdminShell.svelte b/frontend/src/lib/components/AdminShell.svelte new file mode 100644 index 0000000..ce9a4ba --- /dev/null +++ b/frontend/src/lib/components/AdminShell.svelte @@ -0,0 +1,333 @@ + + + + {pageTitle(page.url.pathname)} | Lean 101 Admin Panel + + +
+ + +
+
+
+

Admin Area

+

{pageTitle(page.url.pathname)}

+
+ + {#if $adminSession} +
+ {initials($adminSession.name)} +
+ {$adminSession.name} + {$adminSession.email} +
+
+ {:else} +
+ A +
+ Admin sign-in required + Use `/admin` to authenticate +
+
+ {/if} +
+ +
+ {#if isProtectedRoute && !$adminSession} +
+

Restricted

+

Sign in through the Lean 101 Admin Panel to continue.

+

Client access controls are only available inside the separate admin workspace.

+ Go to admin sign-in +
+ {:else} + {@render children()} + {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/ClientAccessWorkspace.svelte b/frontend/src/lib/components/ClientAccessWorkspace.svelte new file mode 100644 index 0000000..e231d50 --- /dev/null +++ b/frontend/src/lib/components/ClientAccessWorkspace.svelte @@ -0,0 +1,857 @@ + + +
+
+

Client Access Control

+

Manage client users, feature flags, and Power BI-ready access data from one admin workspace.

+

The preview stays aligned with the export payload so access changes and reporting stay in sync.

+
+
+ +
+
+ Total Clients + {clients.length} +

Accounts currently staged in the client app

+
+ +
+ Total Users + {totalUsers} +

New and existing users across every client

+
+ +
+ Enabled Features + {totalEnabledFeatures} +

Feature switches currently turned on

+
+
+ +
+
+
+
+

Clients

+

Select a client before amending users or feature access.

+
+
+ +
+ {#each clients as client} + + {/each} +
+
+ +
+
+
+

Selected Client

+

{selectedClient?.name ?? 'No client selected'}

+

{selectedClient?.powerbi_workspace ?? 'No Power BI workspace assigned yet.'}

+
+ {#if selectedClient} + {selectedClient.status} + {/if} +
+ +
+
+ Existing users + {selectedClient ? selectedClient.users.length - selectedClient.new_user_count : 0} +
+
+ New users + {selectedClient?.new_user_count ?? 0} +
+
+ Enabled features + {selectedClient?.enabled_feature_count ?? 0}/{selectedClient?.total_feature_count ?? 0} +
+
+ +
+
+

Add New User

+ Creates the user and immediately updates the export preview. +
+ +
+ + + + + + + +
+ + + +
+ + {#if formError} + {formError} + {/if} + {#if !formError && formSuccess} + {formSuccess} + {/if} +
+
+ +
+

Existing Users

+ Roles, lifecycle state, and new-user status can be amended inline. +
+ +
+ + + + + + + + + + + + {#each selectedClient?.users ?? [] as user} + + + + + + + + {/each} + +
UserRoleStatusNew UserLast Login
+
+ {initials(user.full_name)} +
+ {user.full_name} + {user.email} +
+
+
+ + + + + + +
+ {user.status} + {formatDate(user.last_login_at)} +
+
+
+
+ +
+
+
+

Feature Access

+

Every client feature can be switched on or off independently.

+
+
+ +
+ {#each selectedClient?.features ?? [] as feature} +
+
+
+ {feature.feature_name} + {feature.feature_group} +
+

{feature.description}

+
+ + +
+ {/each} +
+
+
+ +
+
+
+
+

Power BI Preview

+

Export Shape

+

{previewStatus}

+
+ GET /api/powerbi/client-access +
+ +
+
+ Client rows + {exportPreview.client_rows.length} +
+
+ User rows + {exportPreview.user_rows.length} +
+
+ Feature rows + {exportPreview.feature_rows.length} +
+
+ +
{previewJson}
+
+
+ + diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte new file mode 100644 index 0000000..e7e9e84 --- /dev/null +++ b/frontend/src/lib/components/ClientShell.svelte @@ -0,0 +1,805 @@ + + + + {pageTitle(page.url.pathname)} | Hunter Premium Produce + + +
+ + +
+
+
+

{pageTitle(page.url.pathname)}

+

{pageDescription(page.url.pathname)}

+
+ +
+ {#if $clientSession} + + {:else} +
+ Client + Sign in required +
+ {/if} + + +
+
+ +
+ {#if !isRootRoute && !$clientSession} +
+

Client Sign-In Required

+

Sign in on the Hunter Premium Produce home page to unlock workspace data.

+

The client-facing routes stay empty until a valid client session is active.

+ Return to sign-in +
+ {:else} + {@render children()} + {/if} +
+
+
+ +{#if paletteOpen} + +{/if} + + diff --git a/frontend/src/lib/components/MixWorkspace.svelte b/frontend/src/lib/components/MixWorkspace.svelte new file mode 100644 index 0000000..9fae5f7 --- /dev/null +++ b/frontend/src/lib/components/MixWorkspace.svelte @@ -0,0 +1,884 @@ + + +{#if !$clientSession} +
+

Client Access Required

+

Sign in on the Hunter Premium Produce home page before editing mixes.

+

Mix worksheets use live raw material pricing and save directly into Mix Master.

+ Return to sign-in +
+{:else} +
+
+

{savedMix ? 'Edit Mix' : 'New Mix'}

+

{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}

+

Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.

+
+ +
+ Back to table + +
+
+ + {#if feedback} + + {/if} + + {#if errorMessage} + + {/if} + +
+
+ Live Draft Kg + {totalMixKg.toFixed(2)} +

Total quantity in the current worksheet

+
+ +
+ Live Draft Cost + {currency(totalMixCost)} +

Calculated from current row factors

+
+ +
+ Cost / Kg + {currency(mixCostPerKg, 4)} +

Current worksheet output

+
+
+ +
+
+
+
+

Worksheet Meta

+

Mix details

+
+ +
+ + +
+
+ +
+ + + + + + + +
+ + + +
+
+

Spreadsheet Rows

+

Ingredient builder

+
+ +
+ +
+ + + + + + + + + + + + + + + {#each draftRows as row, index} + + + + + + + + + + + {/each} + +
Raw MaterialMarket ValueWaste %Cost / KgQty KgLine CostNotes
+ + {currency(row.marketValue)}{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}{currency(row.costPerKg, 4)} + + updateIngredientField(index, 'quantity_kg', Number((event.currentTarget as HTMLInputElement).value)) + } + /> + {currency(row.lineCost)} + updateIngredientField(index, 'notes', (event.currentTarget as HTMLInputElement).value)} + placeholder="Optional row note" + /> + + +
+
+
+ + +
+{/if} + + diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts index 850363c..eaac3ab 100644 --- a/frontend/src/lib/mock.ts +++ b/frontend/src/lib/mock.ts @@ -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[] = [ { @@ -32,8 +40,8 @@ export const mockRawMaterials: RawMaterial[] = [ export const mockMixes: Mix[] = [ { id: 1, - client_name: 'Specialty Feeds', - name: 'Pigeon Mix', + client_name: 'Hunter Premium Produce', + name: 'Hunter Orchard Blend', status: 'active', ingredients: [ { @@ -63,10 +71,10 @@ export const mockMixes: Mix[] = [ export const mockProducts: Product[] = [ { id: 1, - name: 'Specialty Pigeon Breeder 20kg', - client_name: 'Specialty Feeds', + name: 'Hunter Orchard Blend 20kg', + client_name: 'Hunter Premium Produce', mix_id: 1, - mix_name: 'Pigeon Mix', + mix_name: 'Hunter Orchard Blend', sale_type: 'standard', unit_of_measure: '20kg bag', distributor_margin: 0.225, @@ -77,7 +85,7 @@ export const mockProducts: Product[] = [ export const mockCosts: ProductCostBreakdown[] = [ { product_id: 1, - product_name: 'Specialty Pigeon Breeder 20kg', + product_name: 'Hunter Orchard Blend 20kg', finished_product_delivered: 14.208, distributor_price: 18.3329, wholesale_price: 17.3268, @@ -94,3 +102,250 @@ export const mockScenarios: Scenario[] = [ 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 +}; diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts index f699573..b49040b 100644 --- a/frontend/src/lib/session.ts +++ b/frontend/src/lib/session.ts @@ -1,58 +1,78 @@ import { browser } from '$app/environment'; import { writable } from 'svelte/store'; -export type OperatorSession = { +export type AppSession = { name: string; email: 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) { return null; } - const value = localStorage.getItem(STORAGE_KEY); + const value = localStorage.getItem(storageKey); if (!value) { return null; } try { - return JSON.parse(value) as OperatorSession; + return JSON.parse(value) as AppSession; } catch { - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(storageKey); return null; } } -function createOperatorSessionStore() { - const store = writable(readSession()); +function createSessionStore(storageKey: string) { + const store = writable(readStoredSession(storageKey)); if (browser) { window.addEventListener('storage', (event) => { - if (event.key === STORAGE_KEY) { - store.set(readSession()); + if (event.key === storageKey) { + store.set(readStoredSession(storageKey)); } }); } return { subscribe: store.subscribe, - set(session: OperatorSession) { + set(session: AppSession) { if (browser) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + localStorage.setItem(storageKey, JSON.stringify(session)); } store.set(session); }, clear() { if (browser) { - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(storageKey); } 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); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ae2c813..fd381aa 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -48,6 +48,34 @@ export type Mix = { 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 = { id: number; tenant_id?: string; @@ -90,10 +118,63 @@ export type Scenario = { overrides: Record; }; +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; + +export type ClientAccessPowerBiExport = { + generated_at: string; + client_rows: ClientAccessExportRow[]; + user_rows: ClientAccessExportRow[]; + feature_rows: ClientAccessExportRow[]; + clients: ClientAccessAccount[]; +}; + export type LoginResponse = { name: string; email: string; role: string; + token: string; + tenant_id?: string | null; }; export type RawMaterialCreateInput = { @@ -119,3 +200,20 @@ export type RawMaterialPriceCreateInput = { status?: string; 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; +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 5f611dd..ccfaa37 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,194 +1,19 @@ - - Data Entry App - - -
-
-
- Data Entry App -

Operator costing workflow

-
- - - -
- {#if $operatorSession} -
- Signed in - {$operatorSession.name} -
- - {:else} - - {/if} -
-
- -
- -
-
- - +{#if isAdminRoute} + + {@render children()} + +{:else} + + {@render children()} + +{/if} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2a251ca..35c9c2f 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,15 +1,55 @@ -{#if !$operatorSession} -
-
-

Costing control room

-

Sign in to manage raw materials and push updates through Mix Master automatically.

-

- This workflow is for operators maintaining input costs. Update a raw material once, then review the refreshed - mix cost per kg and finished product pricing in the same session. -

+{#if !$clientSession} +
+
+

Client Workspace

+

Hunter Premium Produce costing overview.

+

Sign in to load live input pricing, mixes, delivered products, and planning scenarios.

+
+
-
-
- 01 -

Log in

-

Enter the operator account to unlock material maintenance and costing review.

-
-
- 02 -

Update inputs

-

Record a new market value or waste percentage for any raw material.

-
-
- 03 -

Review impact

-

Check Mix Master and downstream product pricing immediately after the save.

-
-
-
+ {:else} -
-
-

Active operator

-

Welcome back, {$operatorSession.name}.

-

Manage raw materials first, then review Mix Master and product pricing before publishing changes.

-
- Open raw material manager -
-{/if} +
+
+

Client Workspace

+

Hunter Premium Produce costing overview.

+

Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.

+
-
- {#each [ - { label: 'Raw materials', value: data.rawMaterials.length, hint: 'Maintain live input costs' }, - { label: 'Mixes', value: data.mixes.length, hint: 'Recipes recalculated from inputs' }, - { label: 'Products', value: data.productCosts.length, hint: 'Delivered pricing outputs' }, - { label: 'Warnings', value: data.dataQuality.length, hint: 'Items needing review' } - ] as card} -
-

{card.label}

- {card.value} - {card.hint} -
- {/each} +
+ + Review Delivered Pricing +
-
-
-
-
-

Latest input

-

{featuredMaterial?.name ?? 'No materials loaded'}

+
+
+

Account

+

Hunter Premium Produce

+

Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.

+
+ +
+ {#each focusCards as card} +
+ {card.code} +
+ {card.label} + {card.detail} +
+ {card.value} +
+ {/each} +
+
+ +
+
+
+ Latest market check +
+ NZD + USD
- Manage inputs
- {#if featuredMaterial?.current_price} -
-
-
Market value
-
{currency(featuredMaterial.current_price.market_value)}
-
-
-
Waste
-
{(featuredMaterial.current_price.waste_percentage * 100).toFixed(1)}%
-
-
-
Cost per kg
-
{currency(featuredMaterial.current_price.cost_per_kg, 4)}
-
-
- {:else} -

No active price version is available for this material.

- {/if} +
+
+

{featuredMaterial?.name ?? 'No material loaded'}

+

{formatDate(featuredMaterial?.current_price?.effective_date)}

+
{currency(featuredMaterial?.current_price?.market_value)}
+

+ {currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg + Current blend for Hunter Premium Produce +

+
+ + +
-
-
+
+
-

Mix master

-

{featuredMix?.name ?? 'No mixes loaded'}

+

Tracked Workspace

+

Entities currently feeding the Hunter costing model

- Review mixes +
- {#if featuredMix} -
-
-
Client
-
{featuredMix.client_name}
-
-
-
Total mix cost
-
{currency(featuredMix.total_mix_cost)}
-
-
-
Cost per kg
-
{currency(featuredMix.mix_cost_per_kg, 4)}
-
-
- {/if} +
+ + +
+ {totalTracked} + tracked items +
+
+ +
+ {#each productionSegments as segment} + {segment.label} {segment.value} + {/each} +
-
-
-
-

Delivered output

-

{featuredProduct?.product_name ?? 'No product costs loaded'}

+
+
+
+ Total Input Spend + +
+ {currency(totalMarketValue)} +

Across all tracked raw materials

+
+ +
+
+ Average Mix Cost + +
+ {currency(averageMixCost, 4)} +

Per kg across the current mix set

+
+ +
+
+ Top Delivered Output + +
+ {currency(featuredProduct?.finished_product_delivered)} +

{featuredProduct?.product_name ?? 'No products loaded'}

+
+
+
+ +
+
+
+
+

Monthly Pricing Pulse

+

Trend view of input pressure and delivered output movement

+
+ +
+ +
- Review products
- {#if featuredProduct} -
+
+
+ Peak {trendFocus.month} + {trendFocus.label} +
+ + +
+ +
+ {#each monthLabels as label} + {label} + {/each} +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
-
Delivered
-
{currency(featuredProduct.finished_product_delivered)}
+

{featuredMix?.name ?? 'Mix Preview'}

+

{featuredMix?.client_name ?? 'Hunter Premium Produce'}

-
-
Distributor
-
{currency(featuredProduct.distributor_price)}
-
-
-
Wholesale
-
{currency(featuredProduct.wholesale_price)}
-
- - {/if} + + Open Mix Master +
+ +
+
+ Ingredients + {featuredMix?.ingredients.length ?? 0} +
+ +
+ Total Kg + {featuredMix?.total_mix_kg ?? 0} +
+ +
+ Total Cost + {currency(featuredMix?.total_mix_cost)} +
+
+
-
-
+
+
-

Current cascade

-

Raw material updates flow straight into Mix Master

+

Priority Watchlist

+

Current client-facing checkpoints generated from the active costing snapshot

- Edit materials
- - - - - - - - - - - {#each data.mixes as mix} +
+
MixIngredientsTotal CostCost/Kg
+ - - - - + + + + - {/each} - -
{mix.name}{mix.ingredients.length}{currency(mix.total_mix_cost)}{currency(mix.mix_cost_per_kg, 4)}FocusOwnerReference DateStatus
+ + + {#each focusCards as card} + + +
+ {card.code} +
+ {card.label} + {card.value} +
+
+ + +
+ HP + Hunter Premium Produce +
+ + +
+ {card.detail} + Current checkpoint +
+ + {card.tone === 'warning' ? 'Watch' : 'On track'} + + {/each} + + +
-
-
+
+
-

Output pricing

-

Finished products

+

Finished Product Summary

+

Highest delivered pricing outputs

- Open products
- - - - - - - - - - - {#each data.productCosts as row} - - - - - - - {/each} - -
ProductDeliveredDistributorWholesale
{row.product_name}{currency(row.finished_product_delivered)}{currency(row.distributor_price)}{currency(row.wholesale_price)}
+
+ {#each topProducts as product} +
+
+ +
+ {product.product_name} + {product.warnings.length ? 'Review warnings' : 'Stable pricing output'} +
+
+ {currency(product.finished_product_delivered)} +
+ {/each} +
+{/if} diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts index ccf585d..4ce65c0 100644 --- a/frontend/src/routes/+page.ts +++ b/frontend/src/routes/+page.ts @@ -1,20 +1,40 @@ +import { hasStoredClientSession } from '$lib/session'; import { api } from '$lib/api'; export async function load() { - const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([ - api.rawMaterials(), - api.mixes(), - api.productCosts(), - api.scenarios(), - api.dataQuality() - ]); + if (!hasStoredClientSession()) { + return { + rawMaterials: [], + mixes: [], + productCosts: [], + scenarios: [], + dataQuality: [] + }; + } - return { - rawMaterials, - mixes, - productCosts, - scenarios, - dataQuality - }; + try { + const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([ + api.rawMaterials(), + api.mixes(), + api.productCosts(), + api.scenarios(), + api.dataQuality() + ]); + + return { + rawMaterials, + mixes, + productCosts, + scenarios, + dataQuality + }; + } catch { + return { + rawMaterials: [], + mixes: [], + productCosts: [], + scenarios: [], + dataQuality: [] + }; + } } - diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..8b24fc8 --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,381 @@ + + +
+
+

Lean 101 Admin Panel

+

Separate operator login and client access controls from the Hunter Premium Produce workspace.

+

Use this admin surface for internal access changes, export validation, and operator-only workflows.

+
+ +
+
+ Managed clients + {data.clients.length} +
+
+ Total users + {totalUsers} +
+
+ Enabled features + {totalFeatures} +
+
+
+ +{#if !$adminSession} + +{:else} +
+
+

Session Active

+

{$adminSession.name} is signed in to the Lean 101 Admin Panel.

+

Open the client access workspace to manage users, feature flags, and the Power BI export preview.

+
+ + +
+{/if} + +
+
+
+
+

Scope

+

What belongs in admin

+
+
+ +
+
+ Client access control + Manage new users, existing users, and feature access by client. +
+
+ Power BI export validation + Verify the live export payload after each access change. +
+
+ Operator-only sign-in + Keep internal authentication separate from the client workspace at `/`. +
+
+
+ +
+
+
+

Preview Snapshot

+

Current export summary

+

Last generated {formatDate(data.exportPreview.generated_at)}

+
+
+ +
+
+ Client rows + {data.exportPreview.client_rows.length} +
+
+ User rows + {data.exportPreview.user_rows.length} +
+
+ Feature rows + {data.exportPreview.feature_rows.length} +
+
+
+
+ + diff --git a/frontend/src/routes/admin/+page.ts b/frontend/src/routes/admin/+page.ts new file mode 100644 index 0000000..f3e7c7b --- /dev/null +++ b/frontend/src/routes/admin/+page.ts @@ -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: [] + } + }; + } +} diff --git a/frontend/src/routes/admin/client-access/+page.svelte b/frontend/src/routes/admin/client-access/+page.svelte new file mode 100644 index 0000000..8bd9c8f --- /dev/null +++ b/frontend/src/routes/admin/client-access/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/routes/admin/client-access/+page.ts b/frontend/src/routes/admin/client-access/+page.ts new file mode 100644 index 0000000..f3e7c7b --- /dev/null +++ b/frontend/src/routes/admin/client-access/+page.ts @@ -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: [] + } + }; + } +} diff --git a/frontend/src/routes/client-access/+page.svelte b/frontend/src/routes/client-access/+page.svelte new file mode 100644 index 0000000..9b51726 --- /dev/null +++ b/frontend/src/routes/client-access/+page.svelte @@ -0,0 +1,857 @@ + + +
+
+

Client Amend Area

+

Control new users, existing users, and every feature flag in one operational workspace.

+

The preview shows the live Power BI export payload after each amendment so the admin surface and reporting output stay aligned.

+
+
+ +
+
+ Total Clients + {clients.length} +

Accounts currently staged in the client app

+
+ +
+ Total Users + {totalUsers} +

New and existing users across every client

+
+ +
+ Enabled Features + {totalEnabledFeatures} +

Feature switches currently turned on

+
+
+ +
+
+
+
+

Clients

+

Select a client before amending users or feature access.

+
+
+ +
+ {#each clients as client} + + {/each} +
+
+ +
+
+
+

Selected Client

+

{selectedClient?.name ?? 'No client selected'}

+

{selectedClient?.powerbi_workspace ?? 'No Power BI workspace assigned yet.'}

+
+ {#if selectedClient} + {selectedClient.status} + {/if} +
+ +
+
+ Existing users + {selectedClient ? selectedClient.users.length - selectedClient.new_user_count : 0} +
+
+ New users + {selectedClient?.new_user_count ?? 0} +
+
+ Enabled features + {selectedClient?.enabled_feature_count ?? 0}/{selectedClient?.total_feature_count ?? 0} +
+
+ +
+
+

Add New User

+ Creates the user and immediately updates the export preview. +
+ +
+ + + + + + + +
+ + + +
+ + {#if formError} + {formError} + {/if} + {#if !formError && formSuccess} + {formSuccess} + {/if} +
+
+ +
+

Existing Users

+ Roles, lifecycle state, and new-user status can be amended inline. +
+ +
+ + + + + + + + + + + + {#each selectedClient?.users ?? [] as user} + + + + + + + + {/each} + +
UserRoleStatusNew UserLast Login
+
+ {initials(user.full_name)} +
+ {user.full_name} + {user.email} +
+
+
+ + + + + + +
+ {user.status} + {formatDate(user.last_login_at)} +
+
+
+
+ +
+
+
+

Feature Access

+

Every client feature can be switched on or off independently.

+
+
+ +
+ {#each selectedClient?.features ?? [] as feature} +
+
+
+ {feature.feature_name} + {feature.feature_group} +
+

{feature.description}

+
+ + +
+ {/each} +
+
+
+ +
+
+
+
+

Power BI Preview

+

Export Shape

+

{previewStatus}

+
+ GET /api/powerbi/client-access +
+ +
+
+ Client rows + {exportPreview.client_rows.length} +
+
+ User rows + {exportPreview.user_rows.length} +
+
+ Feature rows + {exportPreview.feature_rows.length} +
+
+ +
{previewJson}
+
+
+ + diff --git a/frontend/src/routes/client-access/+page.ts b/frontend/src/routes/client-access/+page.ts new file mode 100644 index 0000000..6292eb9 --- /dev/null +++ b/frontend/src/routes/client-access/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export function load() { + throw redirect(307, '/admin/client-access'); +} diff --git a/frontend/src/routes/mixes/+page.svelte b/frontend/src/routes/mixes/+page.svelte index 0bc42db..84ae8fb 100644 --- a/frontend/src/routes/mixes/+page.svelte +++ b/frontend/src/routes/mixes/+page.svelte @@ -1,96 +1,381 @@ -
-

Mix Master

-

Recipes are structured as ingredient rows instead of spreadsheet columns.

+
+
+

Mix Master

+

Saved mixes in a clean table view.

+

Use the table to browse mixes, then open a dedicated worksheet page to edit or create a formulation.

+
-
- {#each data.mixes as mix} -
-
-
-

{mix.name}

-

{mix.client_name}

-
- {mix.status} -
-
-
-
Total Kg
-
{mix.total_mix_kg}
-
-
-
Total Cost
-
${mix.total_mix_cost.toFixed(2)}
-
-
-
Cost/Kg
-
{mix.mix_cost_per_kg ? `$${mix.mix_cost_per_kg.toFixed(4)}` : 'N/A'}
-
-
- {#if mix.warnings.length} -
    - {#each mix.warnings as warning} -
  • {warning}
  • - {/each} -
- {/if} -
- {/each} + +
+ +
+
+ Total Mixes + {data.mixes.length} +

Saved mix definitions

+
+ +
+ Average Cost / Kg + {currency(averageCost, 4)} +

Across all saved mixes

+
+ +
+ Warnings + {warningCount} +

Mixes needing review

+
+
+ +
+
+
+

Table View

+

Saved mixes

+
+ Open any mix to edit +
+ +
+ + + + + + + + + + + + + + + {#each data.mixes as mix} + + + + + + + + + + + {/each} + +
MixClientIngredientsTotal KgTotal CostCost / KgStatus
+
+ MX +
+ {mix.name} + v{mix.version ?? 1} +
+
+
{mix.client_name}{mix.ingredients.length}{mix.total_mix_kg}{currency(mix.total_mix_cost)}{currency(mix.mix_cost_per_kg, 4)} + {mix.status} +
diff --git a/frontend/src/routes/mixes/+page.ts b/frontend/src/routes/mixes/+page.ts index b8cdd5e..9753da3 100644 --- a/frontend/src/routes/mixes/+page.ts +++ b/frontend/src/routes/mixes/+page.ts @@ -1,8 +1,20 @@ +import { hasStoredClientSession } from '$lib/session'; import { api } from '$lib/api'; export async function load() { - return { - mixes: await api.mixes() - }; -} + if (!hasStoredClientSession()) { + return { + mixes: [] + }; + } + try { + return { + mixes: await api.mixes() + }; + } catch { + return { + mixes: [] + }; + } +} diff --git a/frontend/src/routes/mixes/[id]/+page.svelte b/frontend/src/routes/mixes/[id]/+page.svelte new file mode 100644 index 0000000..750858f --- /dev/null +++ b/frontend/src/routes/mixes/[id]/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/routes/mixes/[id]/+page.ts b/frontend/src/routes/mixes/[id]/+page.ts new file mode 100644 index 0000000..0f4d660 --- /dev/null +++ b/frontend/src/routes/mixes/[id]/+page.ts @@ -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'); + } +} diff --git a/frontend/src/routes/mixes/new/+page.svelte b/frontend/src/routes/mixes/new/+page.svelte new file mode 100644 index 0000000..e6178f6 --- /dev/null +++ b/frontend/src/routes/mixes/new/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/routes/mixes/new/+page.ts b/frontend/src/routes/mixes/new/+page.ts new file mode 100644 index 0000000..573bb29 --- /dev/null +++ b/frontend/src/routes/mixes/new/+page.ts @@ -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: [] + }; + } +} diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte index c53f928..829b18b 100644 --- a/frontend/src/routes/products/+page.svelte +++ b/frontend/src/routes/products/+page.svelte @@ -1,59 +1,362 @@ -
-

Products

-

Transparent delivered cost and pricing outputs from backend calculations.

- - - - - - - - - - - - - {#each data.products as product} - {@const cost = data.productCosts.find((item) => item.product_id === product.id)} +
+
+

Output Pricing

+

Products1

+

Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.

+
+
+ +
+
+ Total Products + {rows.length} +

Active finished outputs

+
+ +
+ Highest Delivered Cost + {currency(highestDelivered?.cost?.finished_product_delivered)} +

{highestDelivered?.name ?? 'No product loaded'}

+
+ +
+ Average Delivered Cost + {currency(averageDelivered)} +

Across all tracked products

+
+
+ +
+
+
+

Product Price Table

+

Modern row groups with quick-read badges and healthier spacing.

+
+ + +
+ +
+
ProductMixSale TypeDelivered CostDistributorWholesale
+ - - - - - - + + + + + + - {/each} - -
{product.name}{product.mix_name}{product.sale_type}{cost ? `$${cost.finished_product_delivered.toFixed(2)}` : 'N/A'}{cost?.distributor_price ? `$${cost.distributor_price.toFixed(2)}` : 'N/A'}{cost?.wholesale_price ? `$${cost.wholesale_price.toFixed(2)}` : 'N/A'}ProductMixSale TypeDeliveredMarginsHealth
+ + + {#each rows as row} + + +
+ {initials(row.name)} +
+ {row.name} + {row.client_name} +
+
+ + +
+ {row.mix_name} + {row.unit_of_measure} +
+ + + {row.sale_type} + + +
+ {currency(row.cost?.finished_product_delivered)} + Delivered cost +
+ + +
+ {currency(row.cost?.distributor_price)} / {currency(row.cost?.wholesale_price)} + Distributor / wholesale +
+ + + {row.health} + + + {/each} + + +
diff --git a/frontend/src/routes/products/+page.ts b/frontend/src/routes/products/+page.ts index e1892fe..1ff0b9e 100644 --- a/frontend/src/routes/products/+page.ts +++ b/frontend/src/routes/products/+page.ts @@ -1,10 +1,24 @@ +import { hasStoredClientSession } from '$lib/session'; import { api } from '$lib/api'; export async function load() { - const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]); - return { - products, - productCosts - }; -} + if (!hasStoredClientSession()) { + return { + products: [], + productCosts: [] + }; + } + try { + const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]); + return { + products, + productCosts + }; + } catch { + return { + products: [], + productCosts: [] + }; + } +} diff --git a/frontend/src/routes/raw-materials/+page.svelte b/frontend/src/routes/raw-materials/+page.svelte index d424452..cb94720 100644 --- a/frontend/src/routes/raw-materials/+page.svelte +++ b/frontend/src/routes/raw-materials/+page.svelte @@ -1,8 +1,8 @@ -{#if !$operatorSession} -
-

Operator access required

-

Sign in from the homepage before managing raw materials.

-

This page is the input maintenance area for Mix Master and downstream product pricing.

- Return to login +{#if !$clientSession} +
+

Client Access Required

+

Sign in on the Hunter Premium Produce home page before viewing raw material pricing.

+

This workflow updates source inputs and pushes new values through mix and product calculations.

+ Return to sign-in
{:else} -