v1.4 - Login fixes, etc

This commit is contained in:
2026-04-27 21:53:36 +12:00
parent 8cf9bfb441
commit c9580ac2eb
33 changed files with 2283 additions and 202 deletions
+15 -3
View File
@@ -16,10 +16,10 @@ Create a virtual environment, install dependencies, then run:
```bash ```bash
cd backend cd backend
pip install -e . pip install -e .
uvicorn app.main:app --reload uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
``` ```
API docs will be available at `http://localhost:8000/docs`. API docs will be available at `http://localhost:8000/docs` on the server itself, or `http://<server-ip>:8000/docs` from another machine on the same network.
Useful commands: Useful commands:
@@ -41,7 +41,19 @@ npm install
npm run dev npm run dev
``` ```
Set `PUBLIC_API_BASE_URL` if the backend is not running on `http://localhost:8000`. The frontend dev server now binds to `0.0.0.0`, so you can open it from another machine at `http://<server-ip>:5173`.
By default the browser will call the backend on the same hostname and port `8000`. For example, if you open the UI at `http://10.0.0.124:5173`, it will call `http://10.0.0.124:8000`.
Useful environment variables:
```bash
PUBLIC_API_PORT=8000
PUBLIC_API_BASE_URL=http://10.0.0.124:8000
CORS_ALLOW_ORIGINS=http://10.0.0.124:5173
```
Set `PUBLIC_API_BASE_URL` when the API is on a different machine or behind a different public URL. Set `CORS_ALLOW_ORIGINS` or `CORS_ALLOW_ORIGIN_REGEX` if you want to narrow backend CORS more tightly than the default private-network allowance.
## Delivered in this MVP ## Delivered in this MVP
+23
View File
@@ -2,6 +2,20 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
DEFAULT_CORS_ALLOW_ORIGIN_REGEX = (
r"^https?://("
r"localhost|127\.0\.0\.1|"
r"10\.\d{1,3}\.\d{1,3}\.\d{1,3}|"
r"192\.168\.\d{1,3}\.\d{1,3}|"
r"172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}"
r")(:\d+)?$"
)
def _parse_csv_env(value: str) -> tuple[str, ...]:
return tuple(part.strip() for part in value.split(",") if part.strip())
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
app_name: str app_name: str
@@ -14,6 +28,8 @@ class Settings:
admin_email: str admin_email: str
admin_password: str admin_password: str
auth_secret: str auth_secret: str
cors_allow_origins: tuple[str, ...]
cors_allow_origin_regex: str
@classmethod @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
@@ -28,6 +44,13 @@ class Settings:
admin_email=os.getenv("ADMIN_EMAIL", "admin@lean101.local"), admin_email=os.getenv("ADMIN_EMAIL", "admin@lean101.local"),
admin_password=os.getenv("ADMIN_PASSWORD", "lean101-admin"), admin_password=os.getenv("ADMIN_PASSWORD", "lean101-admin"),
auth_secret=os.getenv("AUTH_SECRET", "lean-101-local-dev-secret"), auth_secret=os.getenv("AUTH_SECRET", "lean-101-local-dev-secret"),
cors_allow_origins=_parse_csv_env(
os.getenv(
"CORS_ALLOW_ORIGINS",
"http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174",
)
),
cors_allow_origin_regex=os.getenv("CORS_ALLOW_ORIGIN_REGEX", DEFAULT_CORS_ALLOW_ORIGIN_REGEX),
) )
+106 -21
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import inspect, text from dataclasses import dataclass, field
from sqlalchemy import MetaData, inspect, text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
@@ -20,6 +22,27 @@ TENANT_TABLES = {
} }
@dataclass(frozen=True)
class MigrationReport:
created_tables: tuple[str, ...] = ()
added_columns: tuple[str, ...] = ()
synced_tenant_rows: dict[str, int] = field(default_factory=dict)
def has_changes(self) -> bool:
return bool(self.created_tables or self.added_columns or self.synced_tenant_rows)
def summary(self) -> str:
parts: list[str] = []
if self.created_tables:
parts.append(f"created tables: {', '.join(self.created_tables)}")
if self.added_columns:
parts.append(f"patched columns: {', '.join(self.added_columns)}")
if self.synced_tenant_rows:
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
parts.append(f"synced tenant rows: {counts}")
return "; ".join(parts) if parts else "schema already up to date"
def _has_column(engine: Engine, table_name: str, column_name: str) -> bool: def _has_column(engine: Engine, table_name: str, column_name: str) -> bool:
inspector = inspect(engine) inspector = inspect(engine)
try: try:
@@ -40,25 +63,41 @@ def _table_exists(engine: Engine, table_name: str) -> bool:
return inspect(engine).has_table(table_name) return inspect(engine).has_table(table_name)
def ensure_tenant_columns(engine: Engine) -> None: def ensure_metadata_tables(engine: Engine, metadata: MetaData) -> tuple[str, ...]:
missing_tables = tuple(table.name for table in metadata.sorted_tables if not _table_exists(engine, table.name))
if missing_tables:
metadata.create_all(bind=engine)
return missing_tables
def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]:
added_columns: list[str] = []
for table_name in TENANT_TABLES: for table_name in TENANT_TABLES:
if _table_exists(engine, table_name): if _table_exists(engine, table_name):
_add_tenant_column(engine, table_name) if not _has_column(engine, table_name, "tenant_id"):
_add_tenant_column(engine, table_name)
added_columns.append(f"{table_name}.tenant_id")
return tuple(added_columns)
def sync_tenant_ids(engine: Engine) -> None: def sync_tenant_ids(engine: Engine) -> dict[str, int]:
if not _table_exists(engine, "client_accounts"): existing_tables = set(inspect(engine).get_table_names())
return
if "client_accounts" not in existing_tables:
return {}
synced_rows: dict[str, int] = {}
with engine.begin() as connection: with engine.begin() as connection:
default_tenant = connection.execute( default_tenant = connection.execute(
text("SELECT tenant_id FROM client_accounts ORDER BY id LIMIT 1") text("SELECT tenant_id FROM client_accounts ORDER BY id LIMIT 1")
).scalar_one_or_none() ).scalar_one_or_none()
if not default_tenant: if not default_tenant:
return return {}
statements = [ statements = [
text( (
"client_users",
text(
""" """
UPDATE client_users UPDATE client_users
SET tenant_id = ( SET tenant_id = (
@@ -68,8 +107,11 @@ def sync_tenant_ids(engine: Engine) -> None:
) )
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"client_feature_access",
text(
""" """
UPDATE client_feature_access UPDATE client_feature_access
SET tenant_id = ( SET tenant_id = (
@@ -79,15 +121,21 @@ def sync_tenant_ids(engine: Engine) -> None:
) )
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"raw_materials",
text(
""" """
UPDATE raw_materials UPDATE raw_materials
SET tenant_id = :default_tenant SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"raw_material_price_versions",
text(
""" """
UPDATE raw_material_price_versions UPDATE raw_material_price_versions
SET tenant_id = ( SET tenant_id = (
@@ -97,8 +145,11 @@ def sync_tenant_ids(engine: Engine) -> None:
) )
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"mixes",
text(
""" """
UPDATE mixes UPDATE mixes
SET tenant_id = COALESCE( SET tenant_id = COALESCE(
@@ -111,8 +162,11 @@ def sync_tenant_ids(engine: Engine) -> None:
) )
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"mix_ingredients",
text(
""" """
UPDATE mix_ingredients UPDATE mix_ingredients
SET tenant_id = ( SET tenant_id = (
@@ -122,8 +176,11 @@ def sync_tenant_ids(engine: Engine) -> None:
) )
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"products",
text(
""" """
UPDATE products UPDATE products
SET tenant_id = COALESCE( SET tenant_id = COALESCE(
@@ -141,15 +198,21 @@ def sync_tenant_ids(engine: Engine) -> None:
) )
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"scenarios",
text(
""" """
UPDATE scenarios UPDATE scenarios
SET tenant_id = :default_tenant SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"costing_results",
text(
""" """
UPDATE costing_results UPDATE costing_results
SET tenant_id = COALESCE( SET tenant_id = COALESCE(
@@ -167,29 +230,51 @@ def sync_tenant_ids(engine: Engine) -> None:
) )
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"process_cost_rules",
text(
""" """
UPDATE process_cost_rules UPDATE process_cost_rules
SET tenant_id = :default_tenant SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"packaging_cost_rules",
text(
""" """
UPDATE packaging_cost_rules UPDATE packaging_cost_rules
SET tenant_id = :default_tenant SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
text( (
"freight_cost_rules",
text(
""" """
UPDATE freight_cost_rules UPDATE freight_cost_rules
SET tenant_id = :default_tenant SET tenant_id = :default_tenant
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
""" """
),
), ),
] ]
for statement in statements: for table_name, statement in statements:
connection.execute(statement, {"default_tenant": default_tenant}) if table_name not in existing_tables:
continue
result = connection.execute(statement, {"default_tenant": default_tenant})
if result.rowcount and result.rowcount > 0:
synced_rows[table_name] = result.rowcount
return synced_rows
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
created_tables = ensure_metadata_tables(engine, metadata)
added_columns = ensure_tenant_columns(engine)
return MigrationReport(created_tables=created_tables, added_columns=added_columns)
+36 -6
View File
@@ -1,7 +1,9 @@
import logging
import os import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from threading import Lock
if __package__ in {None, ""}: if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -19,16 +21,41 @@ from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router from app.api.scenarios import router as scenarios_router
from app.core.config import settings from app.core.config import settings
from app.db.session import Base, engine from app.db.session import Base, engine
from app.db.migrations import ensure_tenant_columns, sync_tenant_ids from app.db.migrations import MigrationReport, bootstrap_schema, sync_tenant_ids
from app.seed import seed_if_empty from app.seed import seed_if_empty
logger = logging.getLogger("data_entry_app.startup")
_database_ready = False
_database_ready_lock = Lock()
def ensure_database_ready() -> MigrationReport:
global _database_ready
if _database_ready:
return MigrationReport()
with _database_ready_lock:
if _database_ready:
return MigrationReport()
schema_report = bootstrap_schema(engine, Base.metadata)
seed_if_empty()
tenant_sync_report = sync_tenant_ids(engine)
report = MigrationReport(
created_tables=schema_report.created_tables,
added_columns=schema_report.added_columns,
synced_tenant_rows=tenant_sync_report,
)
logger.info("Database startup checks complete: %s", report.summary())
_database_ready = True
return report
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine) ensure_database_ready()
ensure_tenant_columns(engine)
seed_if_empty()
sync_tenant_ids(engine)
yield yield
@@ -36,7 +63,8 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:5174"], allow_origins=list(settings.cors_allow_origins),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -81,6 +109,8 @@ def healthcheck():
if __name__ == "__main__": if __name__ == "__main__":
report = ensure_database_ready()
print(f"Database startup checks complete: {report.summary()}")
uvicorn.run( uvicorn.run(
app, app,
host=os.getenv("HOST", "0.0.0.0"), host=os.getenv("HOST", "0.0.0.0"),
+2 -1
View File
@@ -56,6 +56,8 @@ class ProductRead(BaseModel):
class ProductCostBreakdown(BaseModel): class ProductCostBreakdown(BaseModel):
product_id: int product_id: int
product_name: str product_name: str
client_name: str
mix_name: str
cleaned_product_cost: float cleaned_product_cost: float
grading_cost: float grading_cost: float
bagging_cost: float bagging_cost: float
@@ -67,4 +69,3 @@ class ProductCostBreakdown(BaseModel):
wholesale_price: float | None wholesale_price: float | None
warnings: list[str] warnings: list[str]
inputs: dict[str, object] inputs: dict[str, object]
+2
View File
@@ -224,6 +224,8 @@ def calculate_product_cost(db: Session, product_id: int, overrides: dict | None
return { return {
"product_id": product.id, "product_id": product.id,
"product_name": product.name, "product_name": product.name,
"client_name": product.client_name,
"mix_name": product.mix.name if product.mix else "",
"cleaned_product_cost": round(cleaned_product_cost, 4), "cleaned_product_cost": round(cleaned_product_cost, 4),
"grading_cost": round(grading_cost, 4), "grading_cost": round(grading_cost, 4),
"bagging_cost": round(bagging_cost, 4), "bagging_cost": round(bagging_cost, 4),
+76 -1
View File
@@ -1,10 +1,12 @@
from datetime import date from datetime import date
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import settings from app.core.config import settings
from app.db.migrations import bootstrap_schema, sync_tenant_ids
from app.db.session import Base from app.db.session import Base
from app.main import app from app.main import app
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
@@ -77,6 +79,8 @@ def test_mix_and_product_cost_breakdown():
assert mix_result["total_mix_kg"] == 280 assert mix_result["total_mix_kg"] == 280
assert mix_result["mix_cost_per_kg"] == 0.5114 assert mix_result["mix_cost_per_kg"] == 0.5114
assert product_result["client_name"] == "Specialty Feeds"
assert product_result["mix_name"] == "Pigeon Mix"
assert product_result["finished_product_delivered"] == 14.208 assert product_result["finished_product_delivered"] == 14.208
assert product_result["distributor_price"] == 18.3329 assert product_result["distributor_price"] == 18.3329
assert product_result["wholesale_price"] == 17.3268 assert product_result["wholesale_price"] == 17.3268
@@ -181,3 +185,74 @@ def test_client_access_endpoints():
export_response = client.get("/api/powerbi/client-access", headers=headers) export_response = client.get("/api/powerbi/client-access", headers=headers)
assert export_response.status_code == 200 assert export_response.status_code == 200
assert "client_rows" in export_response.json() assert "client_rows" in export_response.json()
def test_bootstrap_schema_creates_missing_tables_and_patches_legacy_tenant_columns():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE client_accounts (
id INTEGER PRIMARY KEY,
tenant_id VARCHAR(64),
name VARCHAR(255),
client_code VARCHAR(64),
status VARCHAR(32),
powerbi_workspace VARCHAR(128),
notes TEXT,
created_at DATETIME
)
"""
)
)
connection.execute(
text(
"""
CREATE TABLE raw_materials (
id INTEGER PRIMARY KEY,
name VARCHAR(255),
supplier VARCHAR(255),
unit_of_measure VARCHAR(64),
kg_per_unit FLOAT,
status VARCHAR(32),
notes TEXT,
created_at DATETIME
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO client_accounts (id, tenant_id, name, client_code, status)
VALUES (1, 'specialty-feeds', 'Specialty Feeds', 'SPEC', 'active')
"""
)
)
connection.execute(
text(
"""
INSERT INTO raw_materials (id, name, supplier, unit_of_measure, kg_per_unit, status)
VALUES (1, 'Maize', 'Example Supplier', 'tonne', 1000, 'active')
"""
)
)
report = bootstrap_schema(engine, Base.metadata)
synced_rows = sync_tenant_ids(engine)
assert "products" in report.created_tables
assert "raw_materials.tenant_id" in report.added_columns
assert "tenant_id" in {column["name"] for column in inspect(engine).get_columns("raw_materials")}
assert synced_rows["raw_materials"] == 1
with engine.begin() as connection:
tenant_id = connection.execute(text("SELECT tenant_id FROM raw_materials WHERE id = 1")).scalar_one()
assert tenant_id == "specialty-feeds"
+350 -3
View File
@@ -12,7 +12,8 @@
"@sveltejs/kit": "^2.7.1", "@sveltejs/kit": "^2.7.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^8.0.0" "vite": "^8.0.0",
"vitest": "^4.0.0"
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
@@ -503,6 +504,17 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -510,6 +522,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -524,6 +543,119 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -547,6 +679,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -557,6 +699,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -567,6 +719,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -605,6 +764,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -630,6 +796,26 @@
} }
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1010,8 +1196,14 @@
"https://github.com/sponsors/sxzz", "https://github.com/sponsors/sxzz",
"https://opencollective.com/debug" "https://opencollective.com/debug"
], ],
"license": "MIT", "license": "MIT"
"peer": true },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
@@ -1103,6 +1295,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -1128,6 +1327,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.55.5", "version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
@@ -1156,6 +1369,23 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -1173,6 +1403,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -1304,6 +1544,113 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+4 -2
View File
@@ -6,13 +6,15 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.2.0", "@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/kit": "^2.7.1", "@sveltejs/kit": "^2.7.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^8.0.0" "vite": "^8.0.0",
"vitest": "^4.0.0"
} }
} }
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from './api';
type JsonRecord = Record<string, unknown>;
function jsonResponse(body: JsonRecord[] | JsonRecord) {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json'
}
});
}
describe('api fetch injection', () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it.each([
{
label: 'raw materials',
call: (fetcher: typeof fetch) => api.rawMaterials(fetcher),
path: '/api/raw-materials',
body: [{ id: 1, name: 'Lime' }]
},
{
label: 'mixes',
call: (fetcher: typeof fetch) => api.mixes(fetcher),
path: '/api/mixes',
body: [{ id: 12, name: 'Orchard Blend' }]
},
{
label: 'products',
call: (fetcher: typeof fetch) => api.products(fetcher),
path: '/api/products',
body: [{ id: 8, name: 'Packhouse Bag' }]
},
{
label: 'product costs',
call: (fetcher: typeof fetch) => api.productCosts(fetcher),
path: '/api/powerbi/product-costs',
body: [{ product_id: 8, finished_product_delivered: 14.2 }]
}
])('uses the injected fetch for $label reads', async ({ call, path, body }) => {
const globalFetch = vi.fn(() => {
throw new Error('global fetch should not be used');
}) as typeof fetch;
const injectedFetch = vi.fn(async () => jsonResponse(body)) as typeof fetch;
globalThis.fetch = globalFetch;
await expect(call(injectedFetch)).resolves.toEqual(body);
expect(injectedFetch).toHaveBeenCalledTimes(1);
expect(injectedFetch.mock.calls[0]?.[0]).toBe(`http://127.0.0.1:8000${path}`);
expect(globalFetch).not.toHaveBeenCalled();
});
});
+39 -14
View File
@@ -28,9 +28,27 @@ import type {
} from '$lib/types'; } from '$lib/types';
import { getStoredAdminSession, getStoredClientSession } from '$lib/session'; import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000'; const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
type AuthMode = 'none' | 'client' | 'admin'; type AuthMode = 'none' | 'client' | 'admin';
type ApiFetch = typeof fetch;
function getApiBaseUrl() {
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl.replace(/\/+$/, '');
}
if (browser) {
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
}
return `http://127.0.0.1:${DEFAULT_API_PORT}`;
}
function buildApiUrl(path: string) {
return `${getApiBaseUrl()}${path}`;
}
function getToken(auth: AuthMode) { function getToken(auth: AuthMode) {
if (!browser) { if (!browser) {
@@ -48,10 +66,10 @@ function getToken(auth: AuthMode) {
return null; return null;
} }
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none'): Promise<T> { async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
try { try {
const token = getToken(auth); const token = getToken(auth);
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined headers: token ? { Authorization: `Bearer ${token}` } : undefined
}); });
if (!response.ok) { if (!response.ok) {
@@ -69,9 +87,14 @@ async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none'):
} }
} }
async function request<T>(path: string, options: RequestInit, auth: AuthMode = 'none'): Promise<T> { async function request<T>(
path: string,
options: RequestInit,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
const token = getToken(auth); const token = getToken(auth);
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetcher(buildApiUrl(path), {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -97,15 +120,17 @@ async function request<T>(path: string, options: RequestInit, auth: AuthMode = '
} }
export const api = { export const api = {
rawMaterials: () => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client'), rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: () => fetchJson('/api/mixes', mockMixes, 'client'), mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher),
mix: (mixId: number) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client'), mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
products: () => fetchJson<Product[]>('/api/products', mockProducts, 'client'), products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: () => fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client'), productCosts: (fetcher?: ApiFetch) =>
scenarios: () => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client'), fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
clientAccess: () => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin'), scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccessExport: () => fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin'), clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin', fetcher),
dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', [], 'client'), clientAccessExport: (fetcher?: ApiFetch) =>
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin', fetcher),
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
clientLogin: (email: string, password: string) => clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', { request<LoginResponse>('/api/auth/client/login', {
method: 'POST', method: 'POST',
+52 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { adminSession } from '$lib/session'; import { adminSession, sessionHydrated } from '$lib/session';
const navigation = [ const navigation = [
{ href: '/admin', label: 'Overview', shortLabel: 'OV' }, { href: '/admin', label: 'Overview', shortLabel: 'OV' },
@@ -8,6 +9,8 @@
]; ];
let { children } = $props(); let { children } = $props();
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
function matchesRoute(href: string, pathname: string) { function matchesRoute(href: string, pathname: string) {
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href); return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
@@ -27,6 +30,34 @@
} }
const isProtectedRoute = $derived(page.url.pathname !== '/admin'); const isProtectedRoute = $derived(page.url.pathname !== '/admin');
$effect(() => {
const hydrated = $sessionHydrated;
const token = $adminSession?.token ?? null;
if (!hydrated) {
return;
}
if (!token) {
isRestoringSession = false;
restoredToken = null;
return;
}
if (restoredToken === token) {
return;
}
restoredToken = token;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
isRestoringSession = false;
}
});
});
</script> </script>
<svelte:head> <svelte:head>
@@ -68,7 +99,15 @@
<h1>{pageTitle(page.url.pathname)}</h1> <h1>{pageTitle(page.url.pathname)}</h1>
</div> </div>
{#if $adminSession} {#if !$sessionHydrated}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Checking saved session</strong>
<span>Restoring admin access</span>
</div>
</div>
{:else if $adminSession}
<div class="profile-card"> <div class="profile-card">
<span class="profile-avatar">{initials($adminSession.name)}</span> <span class="profile-avatar">{initials($adminSession.name)}</span>
<div> <div>
@@ -88,7 +127,13 @@
</header> </header>
<main class="admin-content"> <main class="admin-content">
{#if isProtectedRoute && !$adminSession} {#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)}
<section class="locked-card loading-card">
<p class="eyebrow">Checking Session</p>
<h2>Restoring the Lean 101 admin workspace.</h2>
<p>Refreshing the current route with the saved operator session before prompting for sign-in.</p>
</section>
{:else if isProtectedRoute && !$adminSession}
<section class="locked-card"> <section class="locked-card">
<p class="eyebrow">Restricted</p> <p class="eyebrow">Restricted</p>
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2> <h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
@@ -284,6 +329,10 @@
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08); box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08);
} }
.loading-card {
min-height: 10rem;
}
.locked-card h2, .locked-card h2,
.locked-card p { .locked-card p {
margin: 0; margin: 0;
+673 -63
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { clientSession } from '$lib/session'; import { clientSession, sessionHydrated } from '$lib/session';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import packageInfo from '../../../package.json';
type SearchItem = { type SearchItem = {
href: string; href: string;
@@ -23,6 +25,7 @@
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' }, { href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' } { href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
]; ];
const primaryBottomNavigation = navigation.slice(0, 4);
const searchItems: SearchItem[] = [ const searchItems: SearchItem[] = [
{ {
@@ -55,6 +58,12 @@
description: 'Review delivered product pricing and margins.', description: 'Review delivered product pricing and margins.',
keywords: 'products pricing margins delivered outputs' keywords: 'products pricing margins delivered outputs'
}, },
{
href: '/settings',
label: 'Open Workspace Settings',
description: 'Review account details and workspace preferences.',
keywords: 'settings account preferences profile workspace'
},
{ {
href: '/scenarios', href: '/scenarios',
label: 'Open Scenarios', label: 'Open Scenarios',
@@ -69,7 +78,14 @@
let paletteOpen = $state(false); let paletteOpen = $state(false);
let paletteQuery = $state(''); let paletteQuery = $state('');
let quickMenuOpen = $state(false); let quickMenuOpen = $state(false);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
let paletteInput: HTMLInputElement | null = $state(null); let paletteInput: HTMLInputElement | null = $state(null);
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
function matchesRoute(href: string, pathname: string) { function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href); return href === '/' ? pathname === '/' : pathname.startsWith(href);
@@ -86,6 +102,7 @@
'/mixes': 'Browse saved mix worksheets and costing outputs', '/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce', '/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/products': 'Track delivered product pricing and margin views', '/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions' '/scenarios': 'Compare alternate pricing and production assumptions'
}; };
@@ -96,6 +113,16 @@
paletteQuery = query; paletteQuery = query;
paletteOpen = true; paletteOpen = true;
quickMenuOpen = false; quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
}
function syncViewport() {
showBottomNav = window.innerWidth <= 1180;
if (!showBottomNav) {
navOpen = false;
}
} }
async function runSearchItem(item: SearchItem) { async function runSearchItem(item: SearchItem) {
@@ -104,6 +131,13 @@
await goto(item.href); await goto(item.href);
} }
async function openSettings() {
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
}
const filteredSearchItems = $derived( const filteredSearchItems = $derived(
searchItems.filter((item) => { searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase(); const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
@@ -114,8 +148,10 @@
$effect(() => { $effect(() => {
page.url.pathname; page.url.pathname;
quickMenuOpen = false; quickMenuOpen = false;
userMenuOpen = false;
paletteOpen = false; paletteOpen = false;
paletteQuery = ''; paletteQuery = '';
navOpen = false;
}); });
$effect(() => { $effect(() => {
@@ -124,7 +160,37 @@
} }
}); });
$effect(() => {
const hydrated = $sessionHydrated;
const token = $clientSession?.token ?? null;
if (!hydrated) {
return;
}
if (!token) {
isRestoringSession = false;
restoredToken = null;
return;
}
if (restoredToken === token) {
return;
}
restoredToken = token;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
isRestoringSession = false;
}
});
});
onMount(() => { onMount(() => {
syncViewport();
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
const isTypingField = const isTypingField =
@@ -141,11 +207,18 @@
if (event.key === 'Escape') { if (event.key === 'Escape') {
paletteOpen = false; paletteOpen = false;
quickMenuOpen = false; quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
} }
}; };
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown); window.addEventListener('resize', syncViewport);
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', syncViewport);
};
}); });
</script> </script>
@@ -154,65 +227,79 @@
</svelte:head> </svelte:head>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> {#if showBottomNav && navOpen}
<div class="brand-row"> <button aria-label="Close navigation" class="nav-backdrop" type="button" onclick={() => (navOpen = false)}></button>
<a class="brand" href="/"> {/if}
<span class="brand-mark">HP</span>
<span>Hunter Premium Produce</span>
</a>
<button class="nav-toggle" type="button" aria-label="Navigation options"> {#if !showBottomNav}
<span></span> <aside class="sidebar">
</button> <div class="brand-row">
</div> <a class="brand" href="/">
<span class="brand-mark">HP</span>
<button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}> <span>Hunter Premium Produce</span>
<span class="search-icon"></span>
<span class="search-placeholder">Search the workspace...</span>
<kbd>/</kbd>
</button>
<nav class="nav-list" aria-label="Client navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a> </a>
{/each} </div>
</nav>
<div class="sidebar-footer"> <div class="sidebar-body">
{#each footerLinks as item} <button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<a href={item.href}> <span class="search-icon"></span>
<span class="nav-icon muted">{item.shortLabel}</span> <span class="search-placeholder">Search the workspace...</span>
<span>{item.label}</span> <kbd>/</kbd>
</a> </button>
{/each}
</div>
</aside>
<div class="main-shell"> <nav class="nav-list" aria-label="Client navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#each footerLinks as item}
<a href={item.href}>
<span class="nav-icon muted">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
<div class="sidebar-meta">
<span>{appVersion}</span>
<small>&copy; {currentYear} Hunter Premium Produce</small>
</div>
</div>
</aside>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<header class="topbar"> <header class="topbar">
<div class="topbar-copy"> <div class="topbar-start">
<h1>{pageTitle(page.url.pathname)}</h1> <div class="topbar-copy">
<p>{pageDescription(page.url.pathname)}</p> <h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
</div>
</div>
<div class="topbar-middle">
<button class="search-box topbar-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<span class="search-icon"></span>
<span class="search-placeholder">Search pages, mixes, products, and settings...</span>
<kbd>/</kbd>
</button>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
{#if $clientSession}
<button class="workspace-chip session-chip" type="button" onclick={() => clientSession.clear()}>
<span class="workspace-label">Signed in</span>
<strong>{$clientSession.email}</strong>
</button>
{:else}
<div class="workspace-chip">
<span class="workspace-label">Client</span>
<strong>Sign in required</strong>
</div>
{/if}
<div class="menu-wrap"> <div class="menu-wrap">
<button class="action-button" type="button" onclick={() => (quickMenuOpen = !quickMenuOpen)}> <button
class="action-button"
type="button"
onclick={() => {
quickMenuOpen = !quickMenuOpen;
userMenuOpen = false;
}}
>
Quick Actions Quick Actions
<span class:open={quickMenuOpen} class="chevron"></span> <span class:open={quickMenuOpen} class="chevron"></span>
</button> </button>
@@ -226,11 +313,65 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="menu-wrap user-menu-wrap">
<button
aria-expanded={userMenuOpen}
class="user-trigger"
type="button"
onclick={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
}}
>
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
<span class="user-trigger-copy">
<span class="workspace-label">{$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.email : 'Sign in required') : 'Restoring workspace access'}</strong>
</span>
<span class:open={userMenuOpen} class="chevron"></span>
</button>
{#if userMenuOpen}
<div class="menu-panel user-menu-panel">
<div class="user-menu-summary">
<strong>
{$sessionHydrated
? $clientSession
? $clientSession.name || 'Client account'
: 'Client session inactive'
: 'Checking saved client session'}
</strong>
<span>
{$sessionHydrated
? $clientSession
? $clientSession.email
: 'Return to the overview page to sign in.'
: 'Waiting for the browser session check to complete.'}
</span>
</div>
<button type="button" onclick={openSettings}>Change settings</button>
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>Log out</button>
{:else if !$sessionHydrated}
<button type="button" disabled>Checking session...</button>
{:else}
<a href="/">Go to sign-in</a>
{/if}
</div>
{/if}
</div>
</div> </div>
</header> </header>
<main class="content"> <main class="content">
{#if !isRootRoute && !$clientSession} {#if !isRootRoute && (!$sessionHydrated || isRestoringSession)}
<section class="locked-card loading-card">
<p class="workspace-label">Checking Session</p>
<h2>Restoring your client workspace.</h2>
<p>Refreshing the current page with the saved browser session before deciding whether sign-in is required.</p>
</section>
{:else if !isRootRoute && !$clientSession}
<section class="locked-card"> <section class="locked-card">
<p class="workspace-label">Client Sign-In Required</p> <p class="workspace-label">Client Sign-In Required</p>
<h2>Sign in on the Hunter Premium Produce home page to unlock workspace data.</h2> <h2>Sign in on the Hunter Premium Produce home page to unlock workspace data.</h2>
@@ -244,6 +385,95 @@
</div> </div>
</div> </div>
{#if showBottomNav}
<nav class="bottom-nav" aria-label="Tablet navigation">
{#each primaryBottomNavigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="bottom-nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
<span class="bottom-nav-icon muted">+</span>
<span>More</span>
</button>
</nav>
{#if navOpen}
<section
aria-label="Tablet navigation drawer"
class="bottom-drawer"
role="dialog"
aria-modal="true"
onclick={(event) => event.stopPropagation()}
>
<div class="drawer-handle"></div>
<div class="drawer-header">
<div>
<p class="workspace-label">Workspace Drawer</p>
<strong>Hunter Premium Produce</strong>
</div>
<button aria-label="Close drawer" class="nav-toggle" type="button" onclick={() => (navOpen = false)}>
<span></span>
</button>
</div>
<button class="search-box drawer-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<span class="search-icon"></span>
<span class="search-placeholder">Search the workspace...</span>
<kbd>/</kbd>
</button>
<div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="drawer-section drawer-actions">
<a href="/mixes/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">NW</span>
<span>Create mix worksheet</span>
</a>
<button type="button" onclick={openSettings}>
<span class="nav-icon muted">ST</span>
<span>Change settings</span>
</button>
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon muted">DP</span>
<span>Review delivered pricing</span>
</a>
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon muted">SR</span>
<span>Search the workspace</span>
</button>
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>
<span class="nav-icon muted">SO</span>
<span>Sign out</span>
</button>
{/if}
</div>
</div>
<div class="drawer-footer">
{#each footerLinks as item}
<a href={item.href} onclick={() => (navOpen = false)}>
<span>{item.label}</span>
<small>{item.shortLabel}</small>
</a>
{/each}
</div>
</section>
{/if}
{/if}
{#if paletteOpen} {#if paletteOpen}
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}> <div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
<div <div
@@ -340,6 +570,10 @@
min-height: 100vh; min-height: 100vh;
} }
.nav-backdrop {
display: none;
}
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -349,6 +583,14 @@
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
} }
.sidebar-body {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 1.1rem;
}
.brand-row { .brand-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -401,6 +643,10 @@
color: var(--muted); color: var(--muted);
} }
.topbar-nav-button {
flex-shrink: 0;
}
.nav-toggle span, .nav-toggle span,
.search-icon, .search-icon,
.chevron { .chevron {
@@ -521,6 +767,18 @@
padding-top: 0.6rem; padding-top: 0.6rem;
} }
.sidebar-meta {
display: grid;
gap: 0.2rem;
padding: 0.85rem 0.3rem 0;
color: var(--muted);
font-size: 0.78rem;
}
.sidebar-meta small {
font-size: 0.74rem;
}
.main-shell { .main-shell {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -528,15 +786,22 @@
} }
.topbar { .topbar {
display: flex; display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.1fr) auto;
align-items: center; align-items: center;
justify-content: space-between;
gap: 0.9rem; gap: 0.9rem;
padding: 0.86rem 1.34rem; padding: 0.86rem 1.34rem;
background: var(--panel); background: var(--panel);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.topbar-start {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 0.82rem;
}
.topbar-copy h1, .topbar-copy h1,
.topbar-copy p { .topbar-copy p {
margin: 0; margin: 0;
@@ -553,6 +818,15 @@
font-size: 0.92rem; font-size: 0.92rem;
} }
.topbar-middle {
min-width: 0;
}
.topbar-search {
min-height: 3rem;
background: #fff;
}
.topbar-actions { .topbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -600,6 +874,73 @@
color: #304038; color: #304038;
} }
.user-trigger {
min-width: 14rem;
display: inline-flex;
align-items: center;
gap: 0.72rem;
padding: 0.64rem 0.84rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: var(--panel-soft);
color: #304038;
text-align: left;
cursor: pointer;
}
.user-status-dot {
width: 0.72rem;
height: 0.72rem;
flex-shrink: 0;
border-radius: 999px;
background: #b4c0ba;
box-shadow: 0 0 0 0.24rem rgba(180, 192, 186, 0.2);
}
.user-status-dot.live {
background: var(--green);
box-shadow: 0 0 0 0.24rem rgba(34, 169, 94, 0.14);
}
.user-status-dot.idle {
background: #c08b3d;
box-shadow: 0 0 0 0.24rem rgba(192, 139, 61, 0.14);
}
.user-trigger-copy {
min-width: 0;
display: grid;
flex: 1;
}
.user-trigger-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
}
.user-menu-wrap {
min-width: 0;
}
.user-menu-panel {
min-width: 16rem;
}
.user-menu-summary {
display: grid;
gap: 0.2rem;
padding: 0.72rem 0.78rem;
border-radius: 0.82rem;
background: var(--panel-soft);
}
.user-menu-summary span {
color: var(--muted);
font-size: 0.82rem;
}
.chevron { .chevron {
width: 0.54rem; width: 0.54rem;
height: 0.54rem; height: 0.54rem;
@@ -658,6 +999,10 @@
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.loading-card {
min-height: 10rem;
}
.locked-card h2, .locked-card h2,
.locked-card p { .locked-card p {
margin: 0; margin: 0;
@@ -775,29 +1120,294 @@
justify-content: flex-start; justify-content: flex-start;
} }
@media (max-width: 1080px) { .bottom-nav,
.bottom-drawer {
display: none;
}
.main-shell.bottom-nav-layout .content {
padding-bottom: 7.25rem;
}
@media (max-width: 1180px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sidebar { .nav-backdrop {
border-right: none; position: fixed;
border-bottom: 1px solid var(--line); inset: 0;
z-index: 48;
display: block;
border: none;
background: rgba(11, 18, 14, 0.28);
backdrop-filter: blur(4px);
} }
.sidebar-footer { .bottom-nav {
margin-top: 0; position: fixed;
left: max(0.8rem, env(safe-area-inset-left));
right: max(0.8rem, env(safe-area-inset-right));
bottom: max(0.8rem, env(safe-area-inset-bottom));
z-index: 45;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid rgba(217, 228, 221, 0.92);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 20px 40px rgba(15, 23, 17, 0.16);
backdrop-filter: blur(16px);
}
.bottom-nav a,
.bottom-nav button {
min-width: 0;
display: grid;
justify-items: center;
gap: 0.34rem;
padding: 0.62rem 0.38rem;
border: none;
border-radius: 1rem;
background: transparent;
color: #51635a;
text-align: center;
font-size: 0.74rem;
font-weight: 700;
cursor: pointer;
}
.bottom-nav a.active,
.bottom-nav button.active {
color: var(--green-deep);
background: linear-gradient(180deg, #f4fbf7 0%, #e8f6ee 100%);
}
.bottom-nav-icon {
width: 2.1rem;
height: 2.1rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.78rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.66rem;
letter-spacing: 0.04em;
}
.bottom-nav-icon.muted {
background: linear-gradient(135deg, #96a49c 0%, #718077 100%);
}
.bottom-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: grid;
gap: 1rem;
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
border-radius: 1.6rem 1.6rem 0 0;
background:
linear-gradient(180deg, rgba(248, 251, 249, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
box-shadow: 0 -20px 45px rgba(15, 23, 17, 0.16);
backdrop-filter: blur(16px);
}
.drawer-handle {
width: 3.5rem;
height: 0.34rem;
margin: 0 auto;
border-radius: 999px;
background: #c8d4ce;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.drawer-header strong {
font-size: 1rem;
}
.drawer-search {
background: #fff;
}
.drawer-grid {
display: grid;
gap: 0.9rem;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
.drawer-section {
display: grid;
gap: 0.4rem;
}
.drawer-section a,
.drawer-section button {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.86rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
text-align: left;
cursor: pointer;
}
.drawer-section a.active {
color: var(--green-deep);
background: var(--green-soft);
}
.drawer-footer {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.drawer-footer a {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.82rem 0.9rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
font-weight: 600;
}
.drawer-footer small {
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.topbar {
position: sticky;
top: 0;
z-index: 30;
grid-template-columns: minmax(0, 1fr);
padding: 0.95rem 1rem;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
}
.topbar-middle {
order: 3;
}
.topbar-actions {
justify-content: flex-start;
}
.content {
padding: 1rem;
}
}
@media (min-width: 1181px) {
.bottom-nav-layout .content {
padding-bottom: 1.34rem;
}
}
@media (min-width: 1181px) {
.nav-toggle {
display: none;
}
}
@media (max-width: 900px) {
.drawer-grid {
grid-template-columns: 1fr;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.topbar, .topbar,
.topbar-actions, .topbar-actions {
.action-button { display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.topbar-start {
width: 100%;
}
.topbar-copy {
min-width: 0;
}
.topbar-copy h1 {
font-size: 1.34rem;
}
.topbar-middle {
width: 100%;
}
.bottom-nav {
left: max(0.55rem, env(safe-area-inset-left));
right: max(0.55rem, env(safe-area-inset-right));
bottom: max(0.55rem, env(safe-area-inset-bottom));
gap: 0.32rem;
padding: 0.45rem;
}
.bottom-nav a,
.bottom-nav button {
padding: 0.55rem 0.2rem;
font-size: 0.68rem;
}
.bottom-nav-icon {
width: 1.9rem;
height: 1.9rem;
font-size: 0.6rem;
}
.bottom-drawer {
padding: 0.75rem 0.8rem calc(6.3rem + env(safe-area-inset-bottom));
}
.topbar-actions,
.workspace-chip,
.menu-wrap,
.action-button,
.user-trigger {
width: 100%;
}
.action-button {
justify-content: space-between;
}
.menu-panel {
left: 0;
right: 0;
min-width: 0;
}
.drawer-footer {
grid-template-columns: 1fr;
}
.content { .content {
padding: 0.92rem; padding: 0.92rem;
} }
+105 -12
View File
@@ -415,7 +415,7 @@
<tbody> <tbody>
{#each draftRows as row, index} {#each draftRows as row, index}
<tr> <tr>
<td> <td data-label="Raw Material">
<select <select
value={row.raw_material_id ?? ''} value={row.raw_material_id ?? ''}
onchange={(event) => onchange={(event) =>
@@ -431,10 +431,10 @@
{/each} {/each}
</select> </select>
</td> </td>
<td>{currency(row.marketValue)}</td> <td data-label="Market Value">{currency(row.marketValue)}</td>
<td>{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td> <td data-label="Waste %">{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
<td>{currency(row.costPerKg, 4)}</td> <td data-label="Cost / Kg">{currency(row.costPerKg, 4)}</td>
<td> <td data-label="Qty Kg">
<input <input
type="number" type="number"
min="0" min="0"
@@ -445,8 +445,8 @@
} }
/> />
</td> </td>
<td>{currency(row.lineCost)}</td> <td data-label="Line Cost">{currency(row.lineCost)}</td>
<td> <td data-label="Notes">
<input <input
type="text" type="text"
value={row.notes} value={row.notes}
@@ -454,7 +454,7 @@
placeholder="Optional row note" placeholder="Optional row note"
/> />
</td> </td>
<td> <td data-label="Row Action">
<button class="icon-delete" type="button" onclick={() => removeIngredientRow(index)}>Remove</button> <button class="icon-delete" type="button" onclick={() => removeIngredientRow(index)}>Remove</button>
</td> </td>
</tr> </tr>
@@ -756,6 +756,7 @@
.sheet-table { .sheet-table {
width: 100%; width: 100%;
min-width: 58rem;
border-collapse: separate; border-collapse: separate;
border-spacing: 0 0.54rem; border-spacing: 0 0.54rem;
} }
@@ -860,12 +861,24 @@
background: var(--panel-soft); background: var(--panel-soft);
} }
@media (max-width: 1180px) { @media (max-width: 1240px) {
.editor-grid, .editor-grid {
.metric-row,
.meta-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sidebar-stack {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1180px) {
.metric-row {
grid-template-columns: 1fr;
}
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -877,8 +890,88 @@
align-items: flex-start; align-items: flex-start;
} }
.intro-actions,
.editor-actions,
.primary-button,
.secondary-button {
width: 100%;
}
.summary-grid { .summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.meta-grid,
.sidebar-stack {
grid-template-columns: 1fr;
}
}
@media (max-width: 880px) {
.sheet-table,
.sheet-table thead,
.sheet-table tbody,
.sheet-table tr,
.sheet-table td {
display: block;
width: 100%;
}
.sheet-table {
min-width: 0;
border-spacing: 0;
}
.sheet-table thead {
display: none;
}
.sheet-table tbody {
display: grid;
gap: 0.9rem;
}
.sheet-table tbody tr {
padding: 0.35rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.sheet-table tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
.sheet-table tbody td:first-child,
.sheet-table tbody td:last-child {
border: none;
border-radius: 0;
}
.sheet-table tbody td + td {
border-top: 1px solid var(--line);
}
.sheet-table tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sheet-table input,
.sheet-table select,
.icon-delete {
width: 100%;
min-width: 0;
}
} }
</style> </style>
+2
View File
@@ -86,6 +86,8 @@ export const mockCosts: ProductCostBreakdown[] = [
{ {
product_id: 1, product_id: 1,
product_name: 'Hunter Orchard Blend 20kg', product_name: 'Hunter Orchard Blend 20kg',
client_name: 'Hunter Premium Produce',
mix_name: 'Hunter Orchard Blend',
finished_product_delivered: 14.208, finished_product_delivered: 14.208,
distributor_price: 18.3329, distributor_price: 18.3329,
wholesale_price: 17.3268, wholesale_price: 17.3268,
+15 -1
View File
@@ -1,5 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { writable } from 'svelte/store'; import { readable, writable } from 'svelte/store';
export type AppSession = { export type AppSession = {
name: string; name: string;
@@ -74,5 +74,19 @@ export function hasStoredAdminSession() {
return getStoredAdminSession() !== null; return getStoredAdminSession() !== null;
} }
export const sessionHydrated = readable(false, (set) => {
if (!browser) {
return undefined;
}
const frame = window.requestAnimationFrame(() => {
set(true);
});
return () => {
window.cancelAnimationFrame(frame);
};
});
export const clientSession = createSessionStore(CLIENT_STORAGE_KEY); export const clientSession = createSessionStore(CLIENT_STORAGE_KEY);
export const adminSession = createSessionStore(ADMIN_STORAGE_KEY); export const adminSession = createSessionStore(ADMIN_STORAGE_KEY);
+2
View File
@@ -97,6 +97,8 @@ export type Product = {
export type ProductCostBreakdown = { export type ProductCostBreakdown = {
product_id: number; product_id: number;
product_name: string; product_name: string;
client_name: string;
mix_name: string;
cleaned_product_cost?: number; cleaned_product_cost?: number;
grading_cost?: number; grading_cost?: number;
bagging_cost?: number; bagging_cost?: number;
+157 -14
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { clientSession } from '$lib/session'; import { clientSession, sessionHydrated } from '$lib/session';
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types'; import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
type Segment = { type Segment = {
@@ -42,7 +41,6 @@
try { try {
const session = await api.clientLogin(email, password); const session = await api.clientLogin(email, password);
clientSession.set(session); clientSession.set(session);
await invalidateAll();
} catch (error) { } catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in'; loginError = error instanceof Error ? error.message : 'Unable to sign in';
} finally { } finally {
@@ -253,7 +251,23 @@
const focusCards = $derived(buildFocusCards()); const focusCards = $derived(buildFocusCards());
</script> </script>
{#if !$clientSession} {#if !$sessionHydrated}
<section class="dashboard-intro">
<div>
<p class="eyebrow">Client Workspace</p>
<h2>Restoring your workspace.</h2>
<p>Checking the saved client session before deciding whether sign-in is required.</p>
</div>
</section>
<section class="workspace-banner login-banner loading-banner">
<div>
<p class="eyebrow">Checking Session</p>
<h3>Hold while the app restores your client access state.</h3>
<p>The sign-in form only appears if no valid local session is available.</p>
</div>
</section>
{:else if !$clientSession}
<section class="dashboard-intro"> <section class="dashboard-intro">
<div> <div>
<p class="eyebrow">Client Workspace</p> <p class="eyebrow">Client Workspace</p>
@@ -515,7 +529,7 @@
<tbody> <tbody>
{#each focusCards as card} {#each focusCards as card}
<tr> <tr>
<td class="task-cell"> <td class="task-cell" data-label="Focus">
<div class="table-item"> <div class="table-item">
<span class={`task-icon ${card.tone}`}>{card.code}</span> <span class={`task-icon ${card.tone}`}>{card.code}</span>
<div> <div>
@@ -524,19 +538,21 @@
</div> </div>
</div> </div>
</td> </td>
<td> <td data-label="Owner">
<div class="owner-chip"> <div class="owner-chip">
<span>HP</span> <span>HP</span>
<strong>Hunter Premium Produce</strong> <strong>Hunter Premium Produce</strong>
</div> </div>
</td> </td>
<td> <td data-label="Reference Date">
<div class="due-block"> <div class="due-block">
<strong>{card.detail}</strong> <strong>{card.detail}</strong>
<span>Current checkpoint</span> <span>Current checkpoint</span>
</div> </div>
</td> </td>
<td><span class={`status-chip ${card.tone}`}>{card.tone === 'warning' ? 'Watch' : 'On track'}</span></td> <td data-label="Status">
<span class={`status-chip ${card.tone}`}>{card.tone === 'warning' ? 'Watch' : 'On track'}</span>
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -681,6 +697,10 @@
align-items: center; align-items: center;
} }
.loading-banner {
min-height: 11rem;
}
.signin-form { .signin-form {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -759,8 +779,10 @@
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.15fr) 0.72fr; grid-template-columns: minmax(0, 1.12fr) minmax(0, 1.02fr) minmax(16rem, 0.76fr);
grid-template-areas: 'market gauge metrics';
gap: 1rem; gap: 1rem;
align-items: stretch;
} }
.analysis-grid { .analysis-grid {
@@ -775,6 +797,14 @@
gap: 1rem; gap: 1rem;
} }
.market-card {
grid-area: market;
}
.gauge-card {
grid-area: gauge;
}
.card-toolbar { .card-toolbar {
align-items: flex-start; align-items: flex-start;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -948,6 +978,7 @@
} }
.metric-stack { .metric-stack {
grid-area: metrics;
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
@@ -1130,6 +1161,7 @@
table { table {
width: 100%; width: 100%;
min-width: 46rem;
border-collapse: separate; border-collapse: separate;
border-spacing: 0 0.7rem; border-spacing: 0 0.7rem;
} }
@@ -1293,11 +1325,29 @@
} }
@media (max-width: 1320px) { @media (max-width: 1320px) {
.workspace-banner, .workspace-banner {
.dashboard-grid, align-items: stretch;
.analysis-grid, }
.detail-grid,
.focus-grid { .focus-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
'market gauge'
'metrics metrics';
}
.metric-stack {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1120px) {
.analysis-grid,
.detail-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1306,6 +1356,24 @@
} }
} }
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-areas:
'market'
'gauge'
'metrics';
}
.metric-stack {
grid-template-columns: 1fr;
}
.focus-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) { @media (max-width: 860px) {
.dashboard-intro, .dashboard-intro,
.intro-actions, .intro-actions,
@@ -1320,7 +1388,6 @@
} }
.preview-facts, .preview-facts,
.metric-stack,
.signin-form { .signin-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1339,4 +1406,80 @@
row-gap: 0.5rem; row-gap: 0.5rem;
} }
} }
@media (max-width: 760px) {
.workspace-banner,
.panel-card,
.preview-body {
padding: 1rem;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.task-cell {
min-width: 0;
}
.table-item,
.owner-chip {
align-items: flex-start;
}
}
</style> </style>
+6 -6
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session'; import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { return {
rawMaterials: [], rawMaterials: [],
@@ -14,11 +14,11 @@ export async function load() {
try { try {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([ const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
api.rawMaterials(), api.rawMaterials(fetch),
api.mixes(), api.mixes(fetch),
api.productCosts(), api.productCosts(fetch),
api.scenarios(), api.scenarios(fetch),
api.dataQuality() api.dataQuality(fetch)
]); ]);
return { return {
+15 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api';
import { adminSession } from '$lib/session'; import { adminSession, sessionHydrated } from '$lib/session';
let { data } = $props(); let { data } = $props();
@@ -65,7 +65,15 @@
</div> </div>
</section> </section>
{#if !$adminSession} {#if !$sessionHydrated}
<section class="signin-card loading-card">
<div class="signin-copy">
<p class="eyebrow">Checking Session</p>
<h3>Restoring the Lean 101 admin session before deciding whether sign-in is needed.</h3>
<p>The admin sign-in form only appears when no saved operator session is available.</p>
</div>
</section>
{:else if !$adminSession}
<section class="signin-card"> <section class="signin-card">
<div class="signin-copy"> <div class="signin-copy">
<p class="eyebrow">Admin Sign-In</p> <p class="eyebrow">Admin Sign-In</p>
@@ -253,6 +261,11 @@
align-items: center; align-items: center;
} }
.loading-card {
grid-template-columns: 1fr;
min-height: 10rem;
}
.signin-copy h3, .signin-copy h3,
.live-banner h3, .live-banner h3,
.card-toolbar h3 { .card-toolbar h3 {
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredAdminSession } from '$lib/session'; import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredAdminSession()) { if (!hasStoredAdminSession()) {
return { return {
clients: [], clients: [],
@@ -16,7 +16,7 @@ export async function load() {
} }
try { try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]); const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return { return {
clients, clients,
@@ -1,7 +1,7 @@
import { hasStoredAdminSession } from '$lib/session'; import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredAdminSession()) { if (!hasStoredAdminSession()) {
return { return {
clients: [], clients: [],
@@ -16,7 +16,7 @@ export async function load() {
} }
try { try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]); const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return { return {
clients, clients,
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiMocks = vi.hoisted(() => ({
rawMaterials: vi.fn(),
mixes: vi.fn(),
mix: vi.fn(),
products: vi.fn(),
productCosts: vi.fn(),
scenarios: vi.fn(),
dataQuality: vi.fn(),
clientAccess: vi.fn(),
clientAccessExport: vi.fn()
}));
const sessionMocks = vi.hoisted(() => ({
hasStoredClientSession: vi.fn(),
hasStoredAdminSession: vi.fn()
}));
vi.mock('$lib/api', () => ({
api: apiMocks
}));
vi.mock('$lib/session', () => sessionMocks);
import { load as homeLoad } from './+page';
import { load as adminLoad } from './admin/+page';
import { load as mixesLoad } from './mixes/+page';
import { load as mixNewLoad } from './mixes/new/+page';
import { load as mixDetailLoad } from './mixes/[id]/+page';
import { load as productsLoad } from './products/+page';
import { load as rawMaterialsLoad } from './raw-materials/+page';
import { load as scenariosLoad } from './scenarios/+page';
describe('route loaders use the SvelteKit fetch argument', () => {
const fetcher = vi.fn() as typeof fetch;
beforeEach(() => {
vi.clearAllMocks();
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.hasStoredAdminSession.mockReturnValue(true);
apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]);
apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
apiMocks.mix.mockResolvedValue({ id: 42 });
apiMocks.products.mockResolvedValue([{ id: 3 }]);
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]);
apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]);
apiMocks.clientAccessExport.mockResolvedValue({ generated_at: '', clients: [] });
});
it('passes fetch through the home page loader', async () => {
await homeLoad({ fetch: fetcher } as never);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dataQuality).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the raw materials loader', async () => {
await rawMaterialsLoad({ fetch: fetcher } as never);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.products).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mixes loader', async () => {
await mixesLoad({ fetch: fetcher } as never);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the new mix loader', async () => {
await mixNewLoad({ fetch: fetcher } as never);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix detail loader', async () => {
await mixDetailLoad({ params: { id: '42' }, fetch: fetcher } as never);
expect(apiMocks.mix).toHaveBeenCalledWith(42, fetcher);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the products loader', async () => {
await productsLoad({ fetch: fetcher } as never);
expect(apiMocks.products).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the scenarios loader', async () => {
await scenariosLoad({ fetch: fetcher } as never);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the admin loader', async () => {
await adminLoad({ fetch: fetcher } as never);
expect(apiMocks.clientAccess).toHaveBeenCalledWith(fetcher);
expect(apiMocks.clientAccessExport).toHaveBeenCalledWith(fetcher);
});
});
+188 -21
View File
@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte';
let { data } = $props(); let { data } = $props();
let activeMenuId = $state<number | null>(null); let activeMenuId = $state<number | null>(null);
let activeMenuTrigger = $state<HTMLButtonElement | null>(null);
let menuElement = $state<HTMLDivElement | null>(null);
let menuStyle = $state('');
function currency(value: number | null | undefined, digits = 2) { function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -17,6 +22,86 @@
? data.mixes.reduce((sum, mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length ? data.mixes.reduce((sum, mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
: 0 : 0
); );
const activeMix = $derived(data.mixes.find((mix) => mix.id === activeMenuId) ?? null);
function closeMenu() {
activeMenuId = null;
activeMenuTrigger = null;
menuStyle = '';
}
function positionMenu() {
if (!activeMenuTrigger || !menuElement) {
return;
}
const triggerRect = activeMenuTrigger.getBoundingClientRect();
const menuRect = menuElement.getBoundingClientRect();
const viewportPadding = 12;
const menuGap = 8;
let top = triggerRect.top - menuRect.height - menuGap;
if (top < viewportPadding) {
top = Math.min(window.innerHeight - viewportPadding - menuRect.height, triggerRect.bottom + menuGap);
}
let left = triggerRect.right - menuRect.width;
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - viewportPadding - menuRect.width));
menuStyle = `top: ${Math.max(viewportPadding, top)}px; left: ${left}px;`;
}
async function toggleMenu(id: number, event: MouseEvent) {
if (activeMenuId === id) {
closeMenu();
return;
}
activeMenuId = id;
activeMenuTrigger = event.currentTarget instanceof HTMLButtonElement ? event.currentTarget : null;
await tick();
positionMenu();
}
function handlePointerDown(event: MouseEvent) {
if (activeMenuId === null || !(event.target instanceof Node)) {
return;
}
if (menuElement?.contains(event.target) || activeMenuTrigger?.contains(event.target)) {
return;
}
closeMenu();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu();
}
}
function handleViewportChange() {
if (activeMenuId !== null) {
positionMenu();
}
}
onMount(() => {
window.addEventListener('scroll', handleViewportChange, true);
window.addEventListener('resize', handleViewportChange);
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('scroll', handleViewportChange, true);
window.removeEventListener('resize', handleViewportChange);
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeydown);
};
});
</script> </script>
<section class="page-intro"> <section class="page-intro">
@@ -77,7 +162,7 @@
<tbody> <tbody>
{#each data.mixes as mix} {#each data.mixes as mix}
<tr> <tr>
<td> <td data-label="Mix">
<div class="table-item"> <div class="table-item">
<span class="row-badge">MX</span> <span class="row-badge">MX</span>
<div> <div>
@@ -86,26 +171,25 @@
</div> </div>
</div> </div>
</td> </td>
<td>{mix.client_name}</td> <td data-label="Client">{mix.client_name}</td>
<td>{mix.ingredients.length}</td> <td data-label="Ingredients">{mix.ingredients.length}</td>
<td>{mix.total_mix_kg}</td> <td data-label="Total Kg">{mix.total_mix_kg}</td>
<td>{currency(mix.total_mix_cost)}</td> <td data-label="Total Cost">{currency(mix.total_mix_cost)}</td>
<td>{currency(mix.mix_cost_per_kg, 4)}</td> <td data-label="Cost / Kg">{currency(mix.mix_cost_per_kg, 4)}</td>
<td> <td data-label="Status">
<span class={`status-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span> <span class={`status-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span>
</td> </td>
<td class="menu-cell"> <td class="menu-cell" data-label="Actions">
<div class="menu-wrap"> <div class="menu-wrap">
<button class="menu-trigger" type="button" onclick={() => (activeMenuId = activeMenuId === mix.id ? null : mix.id)}> <button
aria-expanded={activeMenuId === mix.id}
aria-haspopup="menu"
class="menu-trigger"
type="button"
onclick={(event) => toggleMenu(mix.id, event)}
>
Actions Actions
</button> </button>
{#if activeMenuId === mix.id}
<div class="menu-panel">
<a href={`/mixes/${mix.id}`}>Edit worksheet</a>
<a href={`/mixes/${mix.id}`}>Open live cost view</a>
</div>
{/if}
</div> </div>
</td> </td>
</tr> </tr>
@@ -113,6 +197,13 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{#if activeMix}
<div bind:this={menuElement} class="menu-panel" role="menu" style={menuStyle}>
<a href={`/mixes/${activeMix.id}`} onclick={closeMenu}>Edit worksheet</a>
<a href={`/mixes/${activeMix.id}`} onclick={closeMenu}>Open live cost view</a>
</div>
{/if}
</section> </section>
<style> <style>
@@ -235,6 +326,7 @@
table { table {
width: 100%; width: 100%;
min-width: 48rem;
border-collapse: separate; border-collapse: separate;
border-spacing: 0 0.54rem; border-spacing: 0 0.54rem;
} }
@@ -328,10 +420,14 @@
} }
.menu-wrap { .menu-wrap {
position: relative; display: flex;
justify-content: flex-end;
} }
.menu-trigger { .menu-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 0.76rem; border-radius: 0.76rem;
padding: 0.6rem 0.74rem; padding: 0.6rem 0.74rem;
@@ -342,11 +438,10 @@
} }
.menu-panel { .menu-panel {
position: absolute; position: fixed;
top: calc(100% + 0.35rem); z-index: 40;
right: 0;
z-index: 10;
min-width: 10rem; min-width: 10rem;
width: min(14rem, calc(100vw - 1.5rem));
display: grid; display: grid;
gap: 0.18rem; gap: 0.18rem;
padding: 0.32rem; padding: 0.32rem;
@@ -377,5 +472,77 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.table-card {
padding: 1rem;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.menu-wrap,
.menu-trigger {
width: 100%;
}
.menu-trigger {
justify-content: space-between;
}
} }
</style> </style>
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session'; import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { return {
mixes: [] mixes: []
@@ -10,7 +10,7 @@ export async function load() {
try { try {
return { return {
mixes: await api.mixes() mixes: await api.mixes(fetch)
}; };
} catch { } catch {
return { return {
+2 -2
View File
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { hasStoredClientSession } from '$lib/session'; import { hasStoredClientSession } from '$lib/session';
export async function load({ params }) { export async function load({ params, fetch }) {
const mixId = Number(params.id); const mixId = Number(params.id);
if (!Number.isFinite(mixId)) { if (!Number.isFinite(mixId)) {
@@ -17,7 +17,7 @@ export async function load({ params }) {
} }
try { try {
const [mix, rawMaterials] = await Promise.all([api.mix(mixId), api.rawMaterials()]); const [mix, rawMaterials] = await Promise.all([api.mix(mixId, fetch), api.rawMaterials(fetch)]);
return { return {
mix, mix,
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session'; import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { return {
rawMaterials: [] rawMaterials: []
@@ -10,7 +10,7 @@ export async function load() {
try { try {
return { return {
rawMaterials: await api.rawMaterials() rawMaterials: await api.rawMaterials(fetch)
}; };
} catch { } catch {
return { return {
+77 -7
View File
@@ -49,7 +49,7 @@
<section class="page-intro"> <section class="page-intro">
<div> <div>
<p class="eyebrow">Output Pricing</p> <p class="eyebrow">Output Pricing</p>
<h2>Products1</h2> <h2>Delivered product pricing</h2>
<p>Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.</p> <p>Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.</p>
</div> </div>
</section> </section>
@@ -99,7 +99,7 @@
<tbody> <tbody>
{#each rows as row} {#each rows as row}
<tr> <tr>
<td class="product-cell"> <td class="product-cell" data-label="Product">
<div class="product-item"> <div class="product-item">
<span class="product-badge">{initials(row.name)}</span> <span class="product-badge">{initials(row.name)}</span>
<div> <div>
@@ -108,28 +108,28 @@
</div> </div>
</div> </div>
</td> </td>
<td> <td data-label="Mix">
<div class="mix-block"> <div class="mix-block">
<strong>{row.mix_name}</strong> <strong>{row.mix_name}</strong>
<span>{row.unit_of_measure}</span> <span>{row.unit_of_measure}</span>
</div> </div>
</td> </td>
<td> <td data-label="Sale Type">
<span class="sale-pill">{row.sale_type}</span> <span class="sale-pill">{row.sale_type}</span>
</td> </td>
<td> <td data-label="Delivered">
<div class="number-block"> <div class="number-block">
<strong>{currency(row.cost?.finished_product_delivered)}</strong> <strong>{currency(row.cost?.finished_product_delivered)}</strong>
<span>Delivered cost</span> <span>Delivered cost</span>
</div> </div>
</td> </td>
<td> <td data-label="Margins">
<div class="number-block"> <div class="number-block">
<strong>{currency(row.cost?.distributor_price)} / {currency(row.cost?.wholesale_price)}</strong> <strong>{currency(row.cost?.distributor_price)} / {currency(row.cost?.wholesale_price)}</strong>
<span>Distributor / wholesale</span> <span>Distributor / wholesale</span>
</div> </div>
</td> </td>
<td> <td data-label="Health">
<span class={`status-pill ${row.healthTone}`}>{row.health}</span> <span class={`status-pill ${row.healthTone}`}>{row.health}</span>
</td> </td>
</tr> </tr>
@@ -240,6 +240,7 @@
table { table {
width: 100%; width: 100%;
min-width: 48rem;
border-collapse: separate; border-collapse: separate;
border-spacing: 0 0.75rem; border-spacing: 0 0.75rem;
} }
@@ -359,4 +360,73 @@
align-items: flex-start; align-items: flex-start;
} }
} }
@media (max-width: 760px) {
.table-card {
padding: 1rem;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.product-cell {
min-width: 0;
}
}
</style> </style>
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session'; import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { return {
products: [], products: [],
@@ -10,7 +10,7 @@ export async function load() {
} }
try { try {
const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]); const [products, productCosts] = await Promise.all([api.products(fetch), api.productCosts(fetch)]);
return { return {
products, products,
productCosts productCosts
+5 -5
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session'; import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { return {
rawMaterials: [], rawMaterials: [],
@@ -13,10 +13,10 @@ export async function load() {
try { try {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([ const [rawMaterials, mixes, products, productCosts] = await Promise.all([
api.rawMaterials(), api.rawMaterials(fetch),
api.mixes(), api.mixes(fetch),
api.products(), api.products(fetch),
api.productCosts() api.productCosts(fetch)
]); ]);
return { return {
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session'; import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load() { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { return {
scenarios: [] scenarios: []
@@ -10,7 +10,7 @@ export async function load() {
try { try {
return { return {
scenarios: await api.scenarios() scenarios: await api.scenarios(fetch)
}; };
} catch { } catch {
return { return {
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { clientSession } from '$lib/session';
const currentYear = new Date().getFullYear();
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Workspace Settings</p>
<h2>Account and workspace preferences.</h2>
<p>Review your current session, navigation setup, and the client workspace details shown across the app.</p>
</div>
</section>
<section class="settings-grid">
<article class="surface-card">
<p class="eyebrow">Session</p>
<h3>Signed-in account</h3>
<div class="details-list">
<div>
<span>Name</span>
<strong>{$clientSession?.name ?? 'No active session'}</strong>
</div>
<div>
<span>Email</span>
<strong>{$clientSession?.email ?? 'Sign in required'}</strong>
</div>
<div>
<span>Role</span>
<strong>{$clientSession?.role ?? 'Client'}</strong>
</div>
</div>
</article>
<article class="surface-card">
<p class="eyebrow">Display</p>
<h3>Navigation behaviour</h3>
<div class="details-list">
<div>
<span>Desktop</span>
<strong>Left rail navigation</strong>
</div>
<div>
<span>iPad / Tablet</span>
<strong>Bottom navigation drawer</strong>
</div>
<div>
<span>Copyright</span>
<strong>&copy; {currentYear} Hunter Premium Produce</strong>
</div>
</div>
</article>
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #7f8e85;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.settings-grid {
margin-bottom: 1.2rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.details-list span {
color: var(--muted);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.surface-card {
display: grid;
gap: 1rem;
padding: 1.2rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.surface-card h3 {
font-size: 1.1rem;
font-weight: 700;
}
.details-list {
display: grid;
gap: 0.85rem;
}
.details-list div {
padding: 0.9rem 0.95rem;
border: 1px solid var(--line);
border-radius: 0.95rem;
background: var(--panel-soft);
}
.details-list span {
display: block;
margin-bottom: 0.28rem;
font-size: 0.84rem;
}
.details-list strong {
font-size: 0.98rem;
font-weight: 700;
}
@media (max-width: 900px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
</style>
+13 -3
View File
@@ -1,7 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
server: {
host: '0.0.0.0'
},
preview: {
host: '0.0.0.0'
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
clearMocks: true
}
}); });