v1.4 - Login fixes, etc
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+350
-3
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>© {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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>© {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
@@ -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
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user