Deployment Script, Postgres migration, UX improvements

This commit is contained in:
2026-05-08 23:07:01 +12:00
parent 9afc3170ff
commit cfc193b713
37 changed files with 4390 additions and 2715 deletions
+39
View File
@@ -1248,3 +1248,42 @@ Power BI-ready outputs
``` ```
That gives the client safer data entry, gives the consultancy control and visibility, and gives Power BI a clean source instead of fragile workbook logic. That gives the client safer data entry, gives the consultancy control and visibility, and gives Power BI a clean source instead of fragile workbook logic.
---
# Frontend layout debugging notes
## Full-height layouts inside padded shells
When a child layout uses negative margins to cancel a parent container's padding, `height: 100%` is often not enough to visually fill the container.
Example pattern:
```css
.parent {
--content-padding: 1.34rem;
padding: var(--content-padding);
}
.child {
margin: calc(var(--content-padding) * -1);
height: 100%;
}
```
This can leave a visible gap at the bottom because the child is still only `100%` tall while being visually expanded outward by the negative margins.
Preferred fix:
```css
.child {
margin: calc(var(--content-padding) * -1);
height: calc(100% + (var(--content-padding) * 2));
min-height: calc(100% + (var(--content-padding) * 2));
}
```
Worker reasoning rule:
- If a panel "almost" fills the viewport but leaves a strip equal to parent padding, inspect negative margins and the nearest padded scroll container before changing inner child heights.
- In this app, `AppSecondaryRailLayout.svelte` sits inside `ClientShell.svelte`'s padded `.content` container, so full-height fixes should account for `--content-padding`.
+28 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access from app.api.deps import AuthSession, require_client_module_access
@@ -13,14 +13,16 @@ from app.schemas.mix_calculator import (
) )
from app.services.mix_calculator_service import ( from app.services.mix_calculator_service import (
build_mix_calculator_options, build_mix_calculator_options,
can_view_all_mix_calculator_sessions,
calculate_mix_calculator_preview, calculate_mix_calculator_preview,
serialize_mix_calculator_session,
create_mix_calculator_session, create_mix_calculator_session,
get_mix_calculator_session, get_mix_calculator_session,
update_mix_calculator_session,
list_mix_calculator_sessions, list_mix_calculator_sessions,
can_view_all_mix_calculator_sessions, serialize_mix_calculator_session,
update_mix_calculator_session,
) )
from app.services.mix_calculator_pdf import MixCalculatorPdfUnavailableError, build_mix_calculator_pdf
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"]) router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
@@ -77,6 +79,28 @@ def read_mix_calculator_session(
return serialize_mix_calculator_session(session_record, session) return serialize_mix_calculator_session(session_record, session)
@router.get("/{session_id}/pdf")
def download_mix_calculator_session_pdf(
session_id: int,
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
db: Session = Depends(get_db),
):
session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id)
if session_record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found")
try:
pdf_bytes = build_mix_calculator_pdf(session_record)
except MixCalculatorPdfUnavailableError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
filename = mix_calculator_pdf_filename(session_record)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.patch("/{session_id}", response_model=MixCalculatorSessionRead) @router.patch("/{session_id}", response_model=MixCalculatorSessionRead)
def patch_mix_calculator_session( def patch_mix_calculator_session(
session_id: int, session_id: int,
+52 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections import Counter from collections import Counter
from datetime import date, datetime from datetime import date, datetime
import logging import logging
import os
from pathlib import Path from pathlib import Path
import re import re
@@ -22,10 +23,48 @@ from app.services.client_access_service import MODULE_CATALOG, default_access_le
TENANT_ID = "hunter-premium-produce" TENANT_ID = "hunter-premium-produce"
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1) WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
WORKBOOK_SENTINEL_ITEM_ID = "404266" WORKBOOK_SENTINEL_ITEM_ID = "404266"
WORKBOOK_PATH = Path(__file__).resolve().parents[2] / "Input Cost Spreadsheet(1).xlsx" WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx"
logger = logging.getLogger("data_entry_app.seed") logger = logging.getLogger("data_entry_app.seed")
def _workbook_candidates() -> list[Path]:
env_value = os.getenv("WORKBOOK_PATH")
env_path = env_value.strip() if isinstance(env_value, str) and env_value.strip() else None
repo_root = Path(__file__).resolve().parents[2]
cwd = Path.cwd()
candidates = [
Path(env_path) if env_path else None,
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
repo_root / WORKBOOK_FILENAME,
cwd / WORKBOOK_FILENAME,
Path("/app") / WORKBOOK_FILENAME,
Path("/") / WORKBOOK_FILENAME,
]
ordered: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
if candidate is None:
continue
key = str(candidate)
if key in seen:
continue
seen.add(key)
ordered.append(candidate)
return ordered
def _resolve_workbook_path() -> Path:
for candidate in _workbook_candidates():
if candidate.exists():
return candidate
return _workbook_candidates()[0]
WORKBOOK_PATH = _resolve_workbook_path()
def _text(value) -> str | None: def _text(value) -> str | None:
if value is None: if value is None:
return None return None
@@ -129,9 +168,12 @@ def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking
def _load_workbook(): def _load_workbook():
if not WORKBOOK_PATH.exists(): workbook_path = _resolve_workbook_path()
raise FileNotFoundError(f"Workbook not found at {WORKBOOK_PATH}") if not workbook_path.exists():
return load_workbook(WORKBOOK_PATH, data_only=True) raise FileNotFoundError(
f"Workbook not found. Checked: {', '.join(str(path) for path in _workbook_candidates())}"
)
return load_workbook(workbook_path, data_only=True)
def _read_raw_material_rows(workbook) -> list[dict]: def _read_raw_material_rows(workbook) -> list[dict]:
@@ -684,10 +726,14 @@ def seed_costing_workspace(db):
def seed_if_empty(): def seed_if_empty():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
if WORKBOOK_PATH.exists(): workbook_path = _resolve_workbook_path()
if workbook_path.exists():
seed_costing_workspace(db) seed_costing_workspace(db)
else: else:
logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH) logger.warning(
"Skipping costing workspace seed because workbook is missing. Checked: %s",
", ".join(str(path) for path in _workbook_candidates()),
)
seed_client_access(db) seed_client_access(db)
seed_access(db) seed_access(db)
db.commit() db.commit()
@@ -0,0 +1,10 @@
from __future__ import annotations
import re
from app.models.mix_calculator import MixCalculatorSession
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
+295
View File
@@ -0,0 +1,295 @@
from __future__ import annotations
from io import BytesIO
from math import ceil
from app.models.mix_calculator import MixCalculatorSession
class MixCalculatorPdfUnavailableError(RuntimeError):
pass
def _fmt_number(value: float, digits: int = 2) -> str:
return f"{value:.{digits}f}"
def _fractional_bag_warning(session_record: MixCalculatorSession) -> str | None:
rounded_bags = round(session_record.total_bags)
if abs(session_record.total_bags - rounded_bags) < 1e-9:
return None
return (
f"Batch size {session_record.batch_size_kg:g}kg produces {session_record.total_bags:.2f} bags "
f"for {session_record.product_unit_of_measure}. This is not a whole-bag quantity."
)
def build_mix_calculator_pdf(session_record: MixCalculatorSession) -> bytes:
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
except ModuleNotFoundError as exc:
raise MixCalculatorPdfUnavailableError(
"PDF generation is unavailable because 'reportlab' is not installed. "
"Install backend dependencies again to enable PDF export."
) from exc
buffer = BytesIO()
document = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
title=f"{session_record.session_number} - {session_record.product_name}",
author="Lean 101 Clients",
)
styles = getSampleStyleSheet()
eyebrow = ParagraphStyle(
"Eyebrow",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=8,
leading=10,
textColor=colors.HexColor("#62736B"),
spaceAfter=5,
)
title = ParagraphStyle(
"Title",
parent=styles["Heading1"],
fontName="Helvetica-Bold",
fontSize=24,
leading=26,
textColor=colors.HexColor("#21312A"),
spaceAfter=6,
)
subtitle = ParagraphStyle(
"Subtitle",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=10,
leading=13,
textColor=colors.HexColor("#6B7A73"),
)
label = ParagraphStyle(
"Label",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=7,
leading=9,
textColor=colors.HexColor("#6B7A73"),
)
value = ParagraphStyle(
"Value",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=11,
leading=13,
textColor=colors.HexColor("#21312A"),
)
card_value = ParagraphStyle(
"CardValue",
parent=value,
fontSize=16,
leading=18,
)
body = ParagraphStyle(
"Body",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=9,
leading=12,
textColor=colors.HexColor("#304038"),
)
section_title = ParagraphStyle(
"SectionTitle",
parent=styles["Heading2"],
fontName="Helvetica-Bold",
fontSize=13,
leading=15,
textColor=colors.HexColor("#21312A"),
)
warnings = []
bag_warning = _fractional_bag_warning(session_record)
if bag_warning:
warnings.append(bag_warning)
story = [
Paragraph(f"Mix Calculator | {session_record.session_number}", eyebrow),
Paragraph(session_record.product_name, title),
Paragraph(f"{session_record.client_name} &nbsp;&middot;&nbsp; {session_record.mix_name}", subtitle),
Spacer(1, 8),
]
header_table = Table(
[
[
[
Paragraph("Mix date", label),
Paragraph(session_record.mix_date.strftime("%d %b %Y"), value),
],
[
Paragraph("Prepared by", label),
Paragraph(session_record.prepared_by_name, value),
],
[
Paragraph("Status", label),
Paragraph(session_record.status.title(), value),
],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
)
header_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 9),
("BOTTOMPADDING", (0, 0), (-1, -1), 9),
]
)
)
story.extend([header_table, Spacer(1, 10)])
summary_table = Table(
[
[
[Paragraph("Batch size", label), Paragraph(f"{_fmt_number(session_record.batch_size_kg)}kg", card_value)],
[Paragraph("Total output", label), Paragraph(f"{_fmt_number(session_record.total_kg)}kg", card_value)],
[Paragraph("Bags", label), Paragraph(_fmt_number(session_record.total_bags), card_value)],
[Paragraph("Unit pack", label), Paragraph(f"{_fmt_number(session_record.product_unit_size_kg)}kg", card_value)],
]
],
colWidths=[43 * mm, 43 * mm, 43 * mm, 43 * mm],
)
summary_table.setStyle(
TableStyle(
[
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F9FBFA")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
story.extend([summary_table, Spacer(1, 10)])
detail_table = Table(
[
[
[Paragraph("Mix source", label), Paragraph(session_record.mix_name, value), Paragraph(f"Saved against {session_record.product_unit_of_measure} units.", body)],
[Paragraph("Composition", label), Paragraph(f"{_fmt_number(sum(line.mix_percentage for line in session_record.lines))}%", value), Paragraph(f"{len(session_record.lines)} raw material{'s' if len(session_record.lines) != 1 else ''} in the blend.", body)],
[Paragraph("Estimated pages", label), Paragraph(str(max(1, ceil(len(session_record.lines) / 18))), value), Paragraph("Formatted for A4 PDF export.", body)],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
)
detail_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F4F8F5")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
story.extend([detail_table, Spacer(1, 10)])
if session_record.notes:
notes_table = Table(
[[Paragraph("Notes", label)], [Paragraph(session_record.notes.replace("\n", "<br/>"), body)]],
colWidths=[172 * mm],
)
notes_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#F4F8F5")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([notes_table, Spacer(1, 10)])
if warnings:
warning_rows = [[Paragraph("Warnings", label)]]
warning_rows.extend([[Paragraph(warning, body)] for warning in warnings])
warnings_table = Table(warning_rows, colWidths=[172 * mm])
warnings_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#FFF5E6")),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF9EF")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#E8C483")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([warnings_table, Spacer(1, 10)])
story.extend(
[
Paragraph("Required Raw Materials", label),
Paragraph("Blend composition", section_title),
Paragraph(f"{session_record.product_unit_of_measure} · {_fmt_number(session_record.product_unit_size_kg)}kg per unit", subtitle),
Spacer(1, 6),
]
)
table_rows = [["Raw material", "Mix %", "Required kg", "Unit"]]
for line in session_record.lines:
table_rows.append(
[
Paragraph(f"<b>{line.raw_material_name}</b>", body),
Paragraph(f"{_fmt_number(line.mix_percentage)}%", body),
Paragraph(f"{_fmt_number(line.required_kg)}kg", body),
Paragraph(line.unit, body),
]
)
composition_table = Table(table_rows, colWidths=[88 * mm, 24 * mm, 34 * mm, 26 * mm], repeatRows=1)
composition_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EEF4F0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#4F6158")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
("TOPPADDING", (0, 0), (-1, 0), 8),
("LEFTPADDING", (0, 0), (-1, -1), 9),
("RIGHTPADDING", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.6, colors.HexColor("#DBE4DE")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBFCFB")]),
]
)
)
story.append(composition_table)
document.build(story)
return buffer.getvalue()
@@ -1,10 +1,13 @@
Metadata-Version: 2.4 Metadata-Version: 2.4
Name: data-entry-app-backend Name: data-entry-app-backend
Version: 0.1.2 Version: 0.1.5
Summary: Costing platform MVP backend Summary: Costing platform MVP backend
Requires-Python: >=3.11 Requires-Python: >=3.11
Requires-Dist: fastapi<1.0,>=0.115 Requires-Dist: fastapi<1.0,>=0.115
Requires-Dist: openpyxl<4.0,>=3.1
Requires-Dist: uvicorn[standard]<1.0,>=0.30 Requires-Dist: uvicorn[standard]<1.0,>=0.30
Requires-Dist: sqlalchemy<3.0,>=2.0 Requires-Dist: sqlalchemy<3.0,>=2.0
Requires-Dist: pydantic<3.0,>=2.8 Requires-Dist: pydantic<3.0,>=2.8
Requires-Dist: pytest<9.0,>=8.0 Requires-Dist: pytest<9.0,>=8.0
Requires-Dist: psycopg[binary]<4.0,>=3.2
Requires-Dist: reportlab<5.0,>=4.2
@@ -2,33 +2,80 @@ pyproject.toml
./app/__init__.py ./app/__init__.py
./app/main.py ./app/main.py
./app/seed.py ./app/seed.py
./app/seed_access.py
./app/api/__init__.py ./app/api/__init__.py
./app/api/access.py
./app/api/auth.py
./app/api/client_access.py
./app/api/dashboard.py
./app/api/deps.py
./app/api/mix_calculator.py
./app/api/mixes.py ./app/api/mixes.py
./app/api/powerbi.py ./app/api/powerbi.py
./app/api/products.py ./app/api/products.py
./app/api/raw_materials.py ./app/api/raw_materials.py
./app/api/scenarios.py ./app/api/scenarios.py
./app/core/__init__.py ./app/core/__init__.py
./app/core/access.py
./app/core/config.py ./app/core/config.py
./app/core/security.py
./app/db/__init__.py ./app/db/__init__.py
./app/db/migrations.py
./app/db/session.py ./app/db/session.py
./app/models/__init__.py ./app/models/__init__.py
./app/models/access.py
./app/models/assumption.py ./app/models/assumption.py
./app/models/client_access.py
./app/models/mix.py ./app/models/mix.py
./app/models/mix_calculator.py
./app/models/product.py ./app/models/product.py
./app/models/raw_material.py ./app/models/raw_material.py
./app/models/scenario.py ./app/models/scenario.py
./app/schemas/__init__.py ./app/schemas/__init__.py
./app/schemas/client_access.py
./app/schemas/mix.py ./app/schemas/mix.py
./app/schemas/mix_calculator.py
./app/schemas/product.py ./app/schemas/product.py
./app/schemas/raw_material.py ./app/schemas/raw_material.py
./app/schemas/scenario.py ./app/schemas/scenario.py
./app/services/__init__.py ./app/services/__init__.py
./app/services/client_access_service.py
./app/services/costing_engine.py ./app/services/costing_engine.py
./app/services/mix_calculator_filenames.py
./app/services/mix_calculator_pdf.py
./app/services/mix_calculator_service.py
./app/services/scenario_engine.py ./app/services/scenario_engine.py
app/__init__.py
app/main.py
app/seed.py
app/api/__init__.py
app/api/mixes.py
app/api/powerbi.py
app/api/products.py
app/api/raw_materials.py
app/api/scenarios.py
app/core/__init__.py
app/core/config.py
app/db/__init__.py
app/db/session.py
app/models/__init__.py
app/models/assumption.py
app/models/mix.py
app/models/product.py
app/models/raw_material.py
app/models/scenario.py
app/schemas/__init__.py
app/schemas/mix.py
app/schemas/product.py
app/schemas/raw_material.py
app/schemas/scenario.py
app/services/__init__.py
app/services/costing_engine.py
app/services/scenario_engine.py
data_entry_app_backend.egg-info/PKG-INFO data_entry_app_backend.egg-info/PKG-INFO
data_entry_app_backend.egg-info/SOURCES.txt data_entry_app_backend.egg-info/SOURCES.txt
data_entry_app_backend.egg-info/dependency_links.txt data_entry_app_backend.egg-info/dependency_links.txt
data_entry_app_backend.egg-info/requires.txt data_entry_app_backend.egg-info/requires.txt
data_entry_app_backend.egg-info/top_level.txt data_entry_app_backend.egg-info/top_level.txt
tests/test_access.py
tests/test_costing_engine.py tests/test_costing_engine.py
@@ -1,5 +1,8 @@
fastapi<1.0,>=0.115 fastapi<1.0,>=0.115
openpyxl<4.0,>=3.1
uvicorn[standard]<1.0,>=0.30 uvicorn[standard]<1.0,>=0.30
sqlalchemy<3.0,>=2.0 sqlalchemy<3.0,>=2.0
pydantic<3.0,>=2.8 pydantic<3.0,>=2.8
pytest<9.0,>=8.0 pytest<9.0,>=8.0
psycopg[binary]<4.0,>=3.2
reportlab<5.0,>=4.2
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"pydantic>=2.8,<3.0", "pydantic>=2.8,<3.0",
"pytest>=8.0,<9.0", "pytest>=8.0,<9.0",
"psycopg[binary]>=3.2,<4.0", "psycopg[binary]>=3.2,<4.0",
"reportlab>=4.2,<5.0",
] ]
[tool.setuptools] [tool.setuptools]
+35
View File
@@ -344,6 +344,41 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
assert operator_detail_response.status_code == 404 assert operator_detail_response.status_code == 404
def test_mix_calculator_pdf_endpoint_returns_pdf():
with TestClient(app) as client:
superadmin_login = client.post(
"/api/auth/client/login",
json={"email": settings.client_email, "password": settings.client_password},
)
headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"}
options_response = client.get("/api/mix-calculator/options", headers=headers)
seeded_product = next(
product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg"
)
create_response = client.post(
"/api/mix-calculator",
json={
"mix_date": "2026-04-29",
"client_name": seeded_product["client_name"],
"product_id": seeded_product["product_id"],
"batch_size_kg": 560,
"prepared_by_name": "Amelia Hart",
"notes": "Morning production run",
},
headers=headers,
)
created = create_response.json()
pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", headers=headers)
assert pdf_response.status_code == 200
assert pdf_response.headers["content-type"] == "application/pdf"
assert "attachment;" in pdf_response.headers["content-disposition"]
assert pdf_response.content.startswith(b"%PDF")
def test_module_permission_blocks_client_module_access(): def test_module_permission_blocks_client_module_access():
with TestClient(app) as client: with TestClient(app) as client:
admin_login_response = client.post( admin_login_response = client.post(
+98 -135
View File
@@ -1,68 +1,45 @@
<# <#
.SYNOPSIS .SYNOPSIS
Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH. Deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
.DESCRIPTION .DESCRIPTION
Runs `docker compose` against `docker-compose.production.yml` on the remote Tars the local source tree, uploads it to the droplet, and runs
host. The same script handles first-time bootstrap and subsequent updates: docker compose up --build. No git required on the server.
* On bootstrap (-Bootstrap): creates the remote directory, clones the The same script handles first-time setup and subsequent updates.
repo (or updates if already present), uploads the local env file, and
brings the stack up with `docker compose ... up -d --build`.
* On update (default): SSHes to the host, fetches the requested branch,
uploads a refreshed env file (if changed), then runs
`docker compose ... up -d --build` followed by a healthcheck.
The script never executes destructive commands without asking, except for
recreating containers (which preserves the named Postgres volume).
.PARAMETER RemoteHost .PARAMETER RemoteHost
Hostname or IP of the Digital Ocean droplet. Required. Hostname or IP of the Digital Ocean droplet. Required.
.PARAMETER RemoteUser .PARAMETER RemoteUser
SSH user on the droplet. Defaults to `root`. SSH user. Defaults to 'root'.
.PARAMETER RemotePath .PARAMETER RemotePath
Absolute path on the droplet where the repo lives. Defaults to Absolute path on the droplet. Defaults to '/srv/lean101-clients'.
`/srv/lean101-clients`.
.PARAMETER Branch
Git branch to deploy. Defaults to `main`.
.PARAMETER RepoUrl
Git URL used during bootstrap when the remote directory is empty.
Required only with -Bootstrap.
.PARAMETER EnvFile .PARAMETER EnvFile
Local path to the env file that should land on the droplet as Local path to the production env file. Defaults to '.env.production'.
`<RemotePath>/.env.production`. Defaults to `.env.production`.
.PARAMETER SshKey .PARAMETER SshKey
Optional path to an SSH private key. If omitted, the script relies on Optional path to an SSH private key.
ssh-agent / default keys.
.PARAMETER ComposeFile .PARAMETER ComposeFile
Compose file name on the remote host. Defaults to Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.
`docker-compose.production.yml`.
.PARAMETER Bootstrap
Run first-time setup (clone, upload env, build, up).
.PARAMETER SkipBuild
Pass `--no-build` to docker compose (use when only env changed).
.PARAMETER Seed .PARAMETER Seed
Run `python -m app.seed` inside the backend container after the stack is up. Run 'python -m app.seed' inside the backend container after the stack is up.
.PARAMETER Logs .PARAMETER Logs
After deploy, tail logs for ~20 lines so you can verify the stack came up. Tail logs for ~60 lines after deploy to verify the stack came up.
.PARAMETER SkipBuild
Pass --no-build to docker compose (use when only env changed).
.EXAMPLE .EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231
.EXAMPLE .EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs
#> #>
[CmdletBinding()] [CmdletBinding()]
@@ -70,150 +47,136 @@ param(
[Parameter(Mandatory = $true)] [string] $RemoteHost, [Parameter(Mandatory = $true)] [string] $RemoteHost,
[string] $RemoteUser = "root", [string] $RemoteUser = "root",
[string] $RemotePath = "/srv/lean101-clients", [string] $RemotePath = "/srv/lean101-clients",
[string] $Branch = "main",
[string] $RepoUrl,
[string] $EnvFile = ".env.production", [string] $EnvFile = ".env.production",
[string] $SshKey, [string] $SshKey,
[string] $ComposeFile = "docker-compose.production.yml", [string] $ComposeFile = "docker-compose.production.yml",
[switch] $Bootstrap,
[switch] $SkipBuild,
[switch] $Seed, [switch] $Seed,
[switch] $Logs [switch] $Logs,
[switch] $SkipBuild
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
function Write-Step($message) { # ── Helpers ───────────────────────────────────────────────────────────────────
Write-Host "==> $message" -ForegroundColor Cyan function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host "!! $msg" -ForegroundColor Yellow }
function Get-RepoRoot {
$dir = Split-Path -Parent $PSScriptRoot
if (-not $dir) { $dir = (Get-Location).Path }
return $dir
} }
function Write-Warn($message) { $RepoRoot = Get-RepoRoot
Write-Host "!! $message" -ForegroundColor Yellow $SshTarget = "$RemoteUser@$RemoteHost"
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no")
if ($SshKey) { $SshOpts += @("-i", $SshKey) }
function Invoke-Ssh([string] $cmd) {
& ssh @SshOpts $SshTarget $cmd
if ($LASTEXITCODE -ne 0) { throw "Remote command failed (exit $LASTEXITCODE): $cmd" }
} }
function Resolve-RepoRoot { function Invoke-Scp([string] $local, [string] $remote) {
$scriptDir = Split-Path -Parent $MyInvocation.ScriptName & scp @SshOpts $local "${SshTarget}:${remote}"
if (-not $scriptDir) { $scriptDir = $PSScriptRoot } if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" }
return (Resolve-Path (Join-Path $scriptDir "..")).Path
} }
$RepoRoot = Resolve-RepoRoot # ── Resolve paths ─────────────────────────────────────────────────────────────
Push-Location $RepoRoot Push-Location $RepoRoot
try { try {
$envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile } $EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
if (-not (Test-Path $envPath)) { if (-not (Test-Path $EnvPath)) {
throw "Env file not found at '$envPath'. Copy .env.production.example to $EnvFile and fill in production secrets first." throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets."
} }
$sshTarget = "$RemoteUser@$RemoteHost" # ── Connectivity check ──────────────────────────────────────────────────────
$sshOpts = @("-o", "StrictHostKeyChecking=accept-new") Write-Step "Checking SSH connectivity to $SshTarget"
if ($SshKey) { $sshOpts += @("-i", $SshKey) }
function Invoke-Ssh([string] $remoteCommand) {
& ssh @sshOpts $sshTarget $remoteCommand
if ($LASTEXITCODE -ne 0) {
throw "Remote command failed (exit $LASTEXITCODE): $remoteCommand"
}
}
function Invoke-Scp([string] $localPath, [string] $remoteDest) {
& scp @sshOpts $localPath "$($sshTarget):$remoteDest"
if ($LASTEXITCODE -ne 0) {
throw "scp failed for $localPath -> $remoteDest"
}
}
Write-Step "Verifying SSH connectivity to $sshTarget"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
Write-Step "Verifying Docker is installed on the droplet" # ── Package source files ────────────────────────────────────────────────────
Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version" Write-Step "Packaging source files (excluding node_modules, caches, etc.)"
if ($Bootstrap) { $TarFile = Join-Path $env:TEMP "lean101-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
if (-not $RepoUrl) {
throw "-RepoUrl is required when using -Bootstrap."
}
Write-Step "Bootstrapping $RemotePath from $RepoUrl ($Branch)"
$bootstrapScript = @"
set -euo pipefail
mkdir -p '$RemotePath'
cd '$RemotePath'
if [ ! -d .git ]; then
git clone --branch '$Branch' '$RepoUrl' .
else
git remote set-url origin '$RepoUrl'
git fetch origin '$Branch'
git checkout '$Branch'
git reset --hard 'origin/$Branch'
fi
"@
Invoke-Ssh $bootstrapScript
} else {
Write-Step "Updating $RemotePath to latest $Branch"
$updateScript = @"
set -euo pipefail
cd '$RemotePath'
git fetch origin '$Branch'
git checkout '$Branch'
git reset --hard 'origin/$Branch'
"@
Invoke-Ssh $updateScript
}
Write-Step "Uploading $EnvFile to $RemotePath/.env.production" $excludes = @(
Invoke-Scp $envPath "$RemotePath/.env.production" "--exclude=./node_modules",
"--exclude=./frontend/node_modules",
"--exclude=./frontend/.svelte-kit",
"--exclude=./frontend/build",
"--exclude=./.git",
"--exclude=./__pycache__",
"--exclude=./backend/__pycache__",
"--exclude=./backend/app/__pycache__",
"--exclude=./**/__pycache__",
"--exclude=./*.pyc",
"--exclude=./.env",
"--exclude=./.env.production",
"--exclude=./.env.alpha",
"--exclude=./data_entry_app.db",
"--exclude=./*.db"
)
& tar -czf $TarFile @excludes -C $RepoRoot .
if ($LASTEXITCODE -ne 0) { throw "tar failed" }
$TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 1)
Write-Host " Archive: $TarFile ($TarSize MB)"
# ── Upload env file ─────────────────────────────────────────────────────────
Write-Step "Uploading env file"
Invoke-Scp $EnvPath "$RemotePath/.env.production"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
$composeArgs = @( # ── Upload and extract source ────────────────────────────────────────────────
"--env-file", ".env.production", Write-Step "Uploading source archive"
"-f", $ComposeFile Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz"
) -join " " Remove-Item $TarFile -Force
$buildFlag = if ($SkipBuild) { "" } else { "--build" } Write-Step "Extracting on server"
Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/lean101-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-deploy.tar.gz"
Write-Step "Pulling base images" # ── Docker compose up ───────────────────────────────────────────────────────
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true" $ComposeArgs = "--env-file .env.production -f $ComposeFile"
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))" Write-Step "Bringing stack up (build=$(-not $SkipBuild))"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
Write-Step "Waiting for backend healthcheck" # ── Health check ────────────────────────────────────────────────────────────
Write-Step "Waiting for backend health check"
$healthScript = @" $healthScript = @"
set -e set -e
cd '$RemotePath' cd '$RemotePath'
for attempt in `$(seq 1 30); do for i in `$(seq 1 30); do
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing) status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing)
case "`$status" in case "`$status" in
healthy|running) healthy|running) echo "backend is `$status"; exit 0 ;;
echo "backend is `$status" *) printf '.'; sleep 4 ;;
exit 0;;
*)
printf '.'
sleep 4;;
esac esac
done done
echo echo; echo 'backend did not become healthy in time' >&2; exit 1
echo 'backend did not become healthy in time' >&2
docker compose $composeArgs ps backend
exit 1
"@ "@
Invoke-Ssh $healthScript Invoke-Ssh $healthScript
# ── Optional seed ───────────────────────────────────────────────────────────
if ($Seed) { if ($Seed) {
Write-Step "Seeding reference data" Write-Step "Seeding reference data"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs exec -T backend python -m app.seed" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed"
} }
Write-Step "Final container status" # ── Final status ────────────────────────────────────────────────────────────
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs ps" Write-Step "Stack status"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
if ($Logs) { if ($Logs) {
Write-Step "Recent logs (last 60 lines)" Write-Step "Recent logs (last 60 lines)"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs logs --tail=60" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60"
} }
Write-Host "Deployment complete." -ForegroundColor Green Write-Host ""
Write-Host "Deployment complete -> https://clients.lean-101.com.au" -ForegroundColor Green
} }
finally { finally {
Pop-Location Pop-Location
+598
View File
@@ -0,0 +1,598 @@
#!/usr/bin/env bash
# =============================================================================
# migrate-to-postgres.sh
#
# Migrates the lean101-clients production stack from SQLite to PostgreSQL.
# Safe: backs up everything before touching anything. Old stack stays live
# until you confirm migration succeeded.
#
# HOW TO USE:
# 1. Copy this file to the server:
# scp deploy/migrate-to-postgres.sh root@<host>:/srv/lean101-clients/deploy/
#
# 2. SSH in and run it directly (must be interactive — not piped):
# ssh root@<host>
# bash /srv/lean101-clients/deploy/migrate-to-postgres.sh
# =============================================================================
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────────
WORK_DIR="/srv/lean101-clients"
BACKEND="lean101-clients-backend"
FRONTEND="lean101-clients-frontend"
NGINX="lean101-clients"
OLD_COMPOSE="docker-compose.yml"
NEW_COMPOSE="docker-compose.production.yml"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="/srv/lean101-clients-backup-$TIMESTAMP"
MIGRATE_SCRIPT="/tmp/lean101_migrate_data.py"
PG_DB_SERVICE="lean101-clients-db"
PG_IMAGE="postgres:16-alpine"
# ── Colour helpers ────────────────────────────────────────────────────────────
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
sep() { echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; }
h1() { sep; echo -e "${BOLD}${CYAN} $1${RESET}"; sep; }
ok() { echo -e " ${GREEN}${RESET} $1"; }
warn() { echo -e " ${YELLOW}${RESET} $1"; }
die() { echo -e " ${RED}✘ FATAL: $1${RESET}" >&2; exit 1; }
info() { echo -e " ${CYAN}${RESET} $1"; }
prompt() { echo -e "\n ${BOLD}$1${RESET}"; }
# ── Rollback instructions ─────────────────────────────────────────────────────
print_rollback() {
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e "${YELLOW} ROLLBACK — to restore the original SQLite stack:${RESET}"
echo ""
echo " cd $WORK_DIR"
echo " docker compose -f $NEW_COMPOSE --env-file .env.production down 2>/dev/null || true"
echo " docker compose -f $OLD_COMPOSE --env-file .env up -d"
echo ""
echo " SQLite backup is at: $BACKUP_DIR/data_entry_app.db"
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo ""
}
trap print_rollback ERR
# =============================================================================
h1 "PHASE 0 — PRE-FLIGHT"
# =============================================================================
[[ $EUID -eq 0 ]] || die "Run this script as root."
[[ -t 0 ]] || die "This script must be run interactively (not piped via stdin)."
cd "$WORK_DIR" || die "Cannot cd to $WORK_DIR"
# Check required containers are running
for C in "$BACKEND" "$FRONTEND" "$NGINX"; do
STATUS=$(docker inspect --format='{{.State.Status}}' "$C" 2>/dev/null || echo missing)
[[ "$STATUS" == "running" ]] || die "Container $C is not running (status: $STATUS). Cannot migrate."
ok "$C is running"
done
# Confirm SQLite DB is reachable inside backend container
SQLITE_EXISTS=$(docker exec "$BACKEND" python -c \
"import os; print('yes' if os.path.exists('/data/data_entry_app.db') else 'no')" 2>/dev/null || echo no)
[[ "$SQLITE_EXISTS" == "yes" ]] || die "SQLite DB not found at /data/data_entry_app.db inside $BACKEND"
ok "SQLite DB reachable inside backend container"
# Check production compose file
[[ -f "$WORK_DIR/$NEW_COMPOSE" ]] && ok "$NEW_COMPOSE already present" || warn "$NEW_COMPOSE not present — will write it"
echo ""
echo -e " ${BOLD}Everything looks good. Starting migration wizard.${RESET}"
echo ""
# =============================================================================
h1 "PHASE 1 — GATHER CONFIGURATION"
# =============================================================================
info "Reading current config from running backend container..."
get_env() { docker exec "$BACKEND" printenv "$1" 2>/dev/null || echo ""; }
APP_NAME=$(get_env APP_NAME)
CLIENT_NAME=$(get_env CLIENT_NAME)
CLIENT_EMAIL=$(get_env CLIENT_EMAIL)
CLIENT_TENANT_ID=$(get_env CLIENT_TENANT_ID)
ADMIN_NAME=$(get_env ADMIN_NAME)
ADMIN_EMAIL=$(get_env ADMIN_EMAIL)
CORS_ALLOW_ORIGINS=$(get_env CORS_ALLOW_ORIGINS)
ORIGIN=$(docker exec "$FRONTEND" printenv ORIGIN 2>/dev/null || echo "https://clients.lean-101.com.au")
PUBLIC_API_BASE_URL=$(docker exec "$FRONTEND" printenv PUBLIC_API_BASE_URL 2>/dev/null || echo "https://clients.lean-101.com.au")
PUBLIC_MIX_CALC_HISTORY=$(docker exec "$FRONTEND" printenv PUBLIC_MIX_CALCULATOR_SESSION_HISTORY 2>/dev/null || echo "false")
PUBLIC_MIX_CALC_SAVE=$(docker exec "$FRONTEND" printenv PUBLIC_MIX_CALCULATOR_SESSION_SAVE 2>/dev/null || echo "false")
CLIENTS_APP_PORT=$(docker inspect "$NGINX" --format='{{range $p, $conf := .NetworkSettings.Ports}}{{if $conf}}{{(index $conf 0).HostPort}}{{end}}{{end}}' 2>/dev/null || echo "8092")
# Auth secret
EXISTING_AUTH_SECRET=$(get_env AUTH_SECRET)
echo ""
echo " Current values extracted from container:"
echo " APP_NAME = $APP_NAME"
echo " CLIENT_NAME = $CLIENT_NAME"
echo " CLIENT_EMAIL = $CLIENT_EMAIL"
echo " CLIENT_TENANT_ID = $CLIENT_TENANT_ID"
echo " ADMIN_NAME = $ADMIN_NAME"
echo " ADMIN_EMAIL = $ADMIN_EMAIL"
echo " CORS_ALLOW_ORIGINS = $CORS_ALLOW_ORIGINS"
echo " CLIENTS_APP_PORT = $CLIENTS_APP_PORT"
# Prompt for secrets
prompt "Enter PostgreSQL password for user 'lean101' (new — you choose this):"
read -r -s POSTGRES_PASSWORD
[[ -n "$POSTGRES_PASSWORD" ]] || die "Postgres password cannot be empty."
echo ""
prompt "Enter CLIENT_PASSWORD (current app client password — press Enter to reuse existing):"
EXISTING_CLIENT_PW=$(get_env CLIENT_PASSWORD)
read -r -s CLIENT_PASSWORD_INPUT
echo ""
CLIENT_PASSWORD="${CLIENT_PASSWORD_INPUT:-$EXISTING_CLIENT_PW}"
[[ -n "$CLIENT_PASSWORD" ]] || die "Client password cannot be empty."
prompt "Enter ADMIN_PASSWORD (current app admin password — press Enter to reuse existing):"
EXISTING_ADMIN_PW=$(get_env ADMIN_PASSWORD)
read -r -s ADMIN_PASSWORD_INPUT
echo ""
ADMIN_PASSWORD="${ADMIN_PASSWORD_INPUT:-$EXISTING_ADMIN_PW}"
[[ -n "$ADMIN_PASSWORD" ]] || die "Admin password cannot be empty."
prompt "Enter AUTH_SECRET (press Enter to reuse existing: ${EXISTING_AUTH_SECRET:0:8}...):"
read -r -s AUTH_SECRET_INPUT
echo ""
AUTH_SECRET="${AUTH_SECRET_INPUT:-$EXISTING_AUTH_SECRET}"
[[ -n "$AUTH_SECRET" ]] || die "Auth secret cannot be empty."
echo ""
ok "All credentials collected."
# =============================================================================
h1 "PHASE 2 — BACKUP"
# =============================================================================
info "Creating backup at $BACKUP_DIR ..."
mkdir -p "$BACKUP_DIR"
# Back up SQLite DB from inside the container
info "Copying SQLite DB from container..."
docker cp "$BACKEND":/data/data_entry_app.db "$BACKUP_DIR/data_entry_app.db"
SQLITE_SIZE=$(du -sh "$BACKUP_DIR/data_entry_app.db" | cut -f1)
ok "SQLite DB backed up ($SQLITE_SIZE) → $BACKUP_DIR/data_entry_app.db"
# Back up env and compose files
[[ -f .env ]] && cp .env "$BACKUP_DIR/.env.original" && ok ".env backed up"
[[ -f .env.alpha ]] && cp .env.alpha "$BACKUP_DIR/.env.alpha.original"
cp "$OLD_COMPOSE" "$BACKUP_DIR/$OLD_COMPOSE.original" && ok "$OLD_COMPOSE backed up"
# Record current container state
docker ps -a > "$BACKUP_DIR/containers_before.txt"
docker volume ls > "$BACKUP_DIR/volumes_before.txt"
ok "Container/volume state recorded"
# SQLite row counts for comparison later
info "Recording SQLite row counts..."
docker exec "$BACKEND" python -c "
import sqlite3
conn = sqlite3.connect('/data/data_entry_app.db')
tables = conn.execute(\"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name\").fetchall()
print('SQLite row counts:')
for (t,) in tables:
try:
count = conn.execute(f'SELECT COUNT(*) FROM {t}').fetchone()[0]
print(f' {t}: {count}')
except Exception as e:
print(f' {t}: ERROR ({e})')
conn.close()
" 2>/dev/null | tee "$BACKUP_DIR/sqlite_row_counts.txt"
echo ""
ok "Backup complete at $BACKUP_DIR"
# =============================================================================
h1 "PHASE 3 — WRITE PRODUCTION CONFIG"
# =============================================================================
DATABASE_URL="postgresql+psycopg://lean101:${POSTGRES_PASSWORD}@db:5432/lean101"
# Write .env.production
info "Writing .env.production..."
cat > "$WORK_DIR/.env.production" <<ENVEOF
APP_NAME=${APP_NAME}
DATABASE_URL=${DATABASE_URL}
CLIENT_NAME=${CLIENT_NAME}
CLIENT_EMAIL=${CLIENT_EMAIL}
CLIENT_PASSWORD=${CLIENT_PASSWORD}
CLIENT_TENANT_ID=${CLIENT_TENANT_ID}
ADMIN_NAME=${ADMIN_NAME}
ADMIN_EMAIL=${ADMIN_EMAIL}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
AUTH_SECRET=${AUTH_SECRET}
CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS}
ORIGIN=${ORIGIN}
PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL}
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=${PUBLIC_MIX_CALC_HISTORY}
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=${PUBLIC_MIX_CALC_SAVE}
CLIENTS_APP_PORT=${CLIENTS_APP_PORT}
POSTGRES_USER=lean101
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=lean101
ENVEOF
chmod 600 "$WORK_DIR/.env.production"
ok ".env.production written (600 permissions)"
# Write docker-compose.production.yml if not present
if [[ ! -f "$WORK_DIR/$NEW_COMPOSE" ]]; then
info "Writing $NEW_COMPOSE..."
cat > "$WORK_DIR/$NEW_COMPOSE" <<'COMPOSEEOF'
services:
db:
container_name: lean101-clients-db
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-lean101}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-lean101}
volumes:
- clients_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lean101} -d ${POSTGRES_DB:-lean101}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
backend:
container_name: lean101-clients-backend
build:
context: .
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
CLIENT_PASSWORD: ${CLIENT_PASSWORD:?CLIENT_PASSWORD is required}
CLIENT_TENANT_ID: ${CLIENT_TENANT_ID:-hunter-premium-produce}
ADMIN_NAME: ${ADMIN_NAME:-Lean 101}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@lean101.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 5
start_period: 25s
frontend:
container_name: lean101-clients-frontend
build:
context: .
dockerfile: frontend/Dockerfile
restart: unless-stopped
environment:
ORIGIN: ${ORIGIN:-https://clients.lean-101.com.au}
PORT: 3000
HOST: 0.0.0.0
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au}
INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000}
PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000}
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY: ${PUBLIC_MIX_CALCULATOR_SESSION_HISTORY:-false}
PUBLIC_MIX_CALCULATOR_SESSION_SAVE: ${PUBLIC_MIX_CALCULATOR_SESSION_SAVE:-false}
depends_on:
backend:
condition: service_healthy
nginx:
container_name: lean101-clients
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
ports:
- "${CLIENTS_APP_PORT:-8092}:80"
volumes:
- ./deploy/nginx/clients.lean-101.conf:/etc/nginx/conf.d/default.conf:ro
volumes:
clients_db_data:
COMPOSEEOF
ok "$NEW_COMPOSE written"
else
ok "$NEW_COMPOSE already exists — not overwritten"
fi
# =============================================================================
h1 "PHASE 4 — START POSTGRES"
# =============================================================================
info "Starting database service from $NEW_COMPOSE..."
docker compose --env-file .env.production -f "$NEW_COMPOSE" up -d db
info "Waiting for Postgres to be healthy (up to 60s)..."
for i in $(seq 1 30); do
HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}starting{{end}}' "$PG_DB_SERVICE" 2>/dev/null || echo missing)
if [[ "$HEALTH" == "healthy" ]]; then
ok "Postgres is healthy"
break
fi
printf " attempt %d/30: %s\n" "$i" "$HEALTH"
sleep 2
if [[ $i -eq 30 ]]; then
docker logs "$PG_DB_SERVICE" --tail=20
die "Postgres did not become healthy in time."
fi
done
# =============================================================================
h1 "PHASE 5 — BOOTSTRAP POSTGRES SCHEMA"
# =============================================================================
info "Running bootstrap_schema on Postgres via backend container..."
docker exec \
-e DATABASE_URL="$DATABASE_URL" \
"$BACKEND" \
python -c "
import os
from sqlalchemy import create_engine
from app.db.migrations import bootstrap_schema
from app.db.session import Base
import app.models # registers all models onto Base.metadata
url = os.environ['DATABASE_URL']
print(f' Connecting to: {url.split(\"@\")[1] if \"@\" in url else url}')
pg_engine = create_engine(url)
result = bootstrap_schema(pg_engine, Base.metadata)
print(f' Result: {result.summary()}')
"
ok "Postgres schema bootstrapped"
# =============================================================================
h1 "PHASE 6 — MIGRATE DATA (SQLite → PostgreSQL)"
# =============================================================================
info "Writing Python migration script..."
cat > "$MIGRATE_SCRIPT" <<'PYEOF'
#!/usr/bin/env python3
"""
Migrate all rows from SQLite (/data/data_entry_app.db) to PostgreSQL.
Runs inside the lean101-clients-backend container.
"""
import os
import sys
from sqlalchemy import create_engine, text, inspect
SQLITE_URL = "sqlite:////data/data_entry_app.db"
PG_URL = os.environ["PG_DATABASE_URL"]
# FK-safe insertion order based on model relationships
TABLE_ORDER = [
"roles",
"permissions",
"role_permissions",
"users",
"client_accounts",
"client_users",
"client_feature_access",
"client_user_module_permissions",
"client_access_audit_events",
"raw_materials",
"raw_material_price_versions",
"mixes",
"mix_ingredients",
"process_cost_rules",
"packaging_cost_rules",
"freight_cost_rules",
"products",
"scenarios",
"costing_results",
"mix_calculator_sessions",
"mix_calculator_session_lines",
]
def migrate():
from app.db.session import Base
import app.models # registers all models onto Base.metadata
src = create_engine(SQLITE_URL, connect_args={"check_same_thread": False})
dst = create_engine(PG_URL)
sqlite_tables = set(inspect(src).get_table_names())
print(f"\nFound {len(sqlite_tables)} tables in SQLite: {', '.join(sorted(sqlite_tables))}\n")
totals = {}
with dst.begin() as dst_conn:
# Disable FK checks for bulk insert
dst_conn.execute(text("SET session_replication_role = 'replica'"))
for table_name in TABLE_ORDER:
if table_name not in sqlite_tables:
print(f" SKIP {table_name:<45} (not in SQLite)")
continue
table = Base.metadata.tables.get(table_name)
if table is None:
print(f" SKIP {table_name:<45} (not in SQLAlchemy metadata)")
continue
try:
with src.connect() as src_conn:
rows = src_conn.execute(table.select()).fetchall()
except Exception as e:
print(f" ERROR {table_name:<45} SQLite read failed: {e}")
continue
if not rows:
print(f" SKIP {table_name:<45} (0 rows)")
continue
try:
dst_conn.execute(table.insert(), [dict(row._mapping) for row in rows])
print(f" OK {table_name:<45} {len(rows):>6} rows")
totals[table_name] = len(rows)
except Exception as e:
print(f" ERROR {table_name:<45} Insert failed: {e}")
sys.exit(1)
# Re-enable FK checks
dst_conn.execute(text("SET session_replication_role = 'origin'"))
# Reset auto-increment sequences
print("\n Resetting sequences...")
with dst.begin() as conn:
for table_name in TABLE_ORDER:
try:
conn.execute(text(
f"SELECT setval("
f" pg_get_serial_sequence('{table_name}', 'id'),"
f" COALESCE((SELECT MAX(id) FROM {table_name}), 1)"
f")"
))
except Exception:
pass
print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.")
return totals
if __name__ == "__main__":
migrate()
PYEOF
info "Copying migration script into backend container..."
docker cp "$MIGRATE_SCRIPT" "$BACKEND:$MIGRATE_SCRIPT"
info "Running migration..."
docker exec \
-e DATABASE_URL="$DATABASE_URL" \
-e PG_DATABASE_URL="$DATABASE_URL" \
"$BACKEND" \
python "$MIGRATE_SCRIPT"
ok "Data migration complete"
# =============================================================================
h1 "PHASE 7 — VERIFY MIGRATION"
# =============================================================================
info "Postgres row counts:"
docker exec "$PG_DB_SERVICE" psql -U lean101 -d lean101 -c "
SELECT
relname AS table_name,
n_live_tup AS row_count
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
" | sed 's/^/ /'
echo ""
echo " SQLite row counts (from backup):"
cat "$BACKUP_DIR/sqlite_row_counts.txt" | sed 's/^/ /'
echo ""
prompt "Review the counts above. Do they match? Type 'yes' to proceed with cutover, anything else to abort:"
read -r CONFIRM
if [[ "$CONFIRM" != "yes" ]]; then
echo ""
warn "Cutover aborted by user. Old SQLite stack is still running."
warn "Postgres is running but old stack is untouched."
echo ""
echo " To retry from data migration step:"
echo " bash $0"
echo ""
echo " To tear down the Postgres container:"
echo " docker compose -f $NEW_COMPOSE --env-file .env.production down"
exit 0
fi
# =============================================================================
h1 "PHASE 8 — CUTOVER (Stop SQLite stack, Start Postgres stack)"
# =============================================================================
info "Stopping old SQLite stack (backend, frontend, nginx)..."
docker stop "$BACKEND" "$FRONTEND" "$NGINX" 2>/dev/null || true
docker rm "$BACKEND" "$FRONTEND" "$NGINX" 2>/dev/null || true
ok "Old containers stopped and removed"
info "Starting full production stack from $NEW_COMPOSE..."
docker compose --env-file .env.production -f "$NEW_COMPOSE" up -d --build
info "Waiting for production backend to become healthy (up to 90s)..."
for i in $(seq 1 30); do
HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}starting{{end}}' "$BACKEND" 2>/dev/null || echo missing)
if [[ "$HEALTH" == "healthy" ]]; then
ok "Backend healthy"
break
fi
printf " attempt %d/30: %s\n" "$i" "$HEALTH"
sleep 3
if [[ $i -eq 30 ]]; then
docker logs "$BACKEND" --tail=30
die "Backend did not become healthy. Check logs above. Run rollback if needed."
fi
done
# =============================================================================
h1 "PHASE 9 — FINAL VERIFICATION"
# =============================================================================
info "Stack status:"
docker compose --env-file .env.production -f "$NEW_COMPOSE" ps | sed 's/^/ /'
echo ""
info "Backend health endpoint:"
docker exec "$BACKEND" python -c \
"import urllib.request; r=urllib.request.urlopen('http://127.0.0.1:8000/health'); print(' ', r.read().decode())"
echo ""
info "DATABASE_URL in production backend:"
docker exec "$BACKEND" printenv DATABASE_URL | \
sed 's|://[^:]*:[^@]*@|://***:***@|g' | sed 's/^/ /'
echo ""
info "Final Postgres table row counts:"
docker exec "$PG_DB_SERVICE" psql -U lean101 -d lean101 -c "
SELECT relname AS table_name, n_live_tup AS rows
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
" | sed 's/^/ /'
# Clean up migration script from container
docker exec "$BACKEND" rm -f "$MIGRATE_SCRIPT" 2>/dev/null || true
rm -f "$MIGRATE_SCRIPT"
# Remove the error trap since we succeeded
trap - ERR
sep
echo -e "${GREEN}${BOLD} Migration complete!${RESET}"
echo ""
echo -e " ${BOLD}Production is now running on PostgreSQL.${RESET}"
echo ""
echo " Backup preserved at: $BACKUP_DIR"
echo " SQLite volume (lean101-clients_clients_app_data) was NOT deleted."
echo " Once you're confident in production, you can remove it with:"
echo " docker volume rm lean101-clients_clients_app_data"
echo ""
echo " Next steps:"
echo " 1. Verify the app at https://clients.lean-101.com.au"
echo " 2. Bootstrap git repo: ./deploy/Deploy.ps1 -RemoteHost <ip> -Bootstrap -RepoUrl <url>"
echo " 3. Future deploys use: ./deploy/Deploy.ps1 -RemoteHost <ip>"
sep
echo ""
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env bash
# =============================================================================
# predeployment-check.sh
#
# Run on the production server to capture full environment state before making
# changes. Safe — read-only, no writes, no restarts.
#
# Usage (from local machine):
# ssh root@<host> 'bash -s' < deploy/predeployment-check.sh
#
# Usage (on the server directly):
# bash /srv/lean101-clients/deploy/predeployment-check.sh
# =============================================================================
set -euo pipefail
REMOTE_PATH="${REMOTE_PATH:-/srv/lean101-clients}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.production.yml}"
ENV_FILE="${ENV_FILE:-.env.production}"
BACKEND_CONTAINER="lean101-clients-backend"
FRONTEND_CONTAINER="lean101-clients-frontend"
NGINX_CONTAINER="lean101-clients"
DB_CONTAINER="lean101-clients-db"
# Colours
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
sep() { echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; }
h1() { sep; echo -e "${BOLD}${CYAN} $1${RESET}"; sep; }
ok() { echo -e " ${GREEN}${RESET} $1"; }
warn() { echo -e " ${YELLOW}${RESET} $1"; }
fail() { echo -e " ${RED}${RESET} $1"; }
kv() { printf " %-30s %s\n" "$1" "$2"; }
# Helper: run a command inside a container, return empty string on failure
cexec() {
local container="$1"; shift
docker exec "$container" "$@" 2>/dev/null || true
}
echo ""
echo -e "${BOLD} LEAN 101 CLIENTS — Pre-Deployment Check${RESET}"
echo -e " Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo -e " Host: $(hostname -f 2>/dev/null || hostname)"
echo ""
# =============================================================================
h1 "1. HOST SYSTEM"
# =============================================================================
kv "OS:" "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
kv "Kernel:" "$(uname -r)"
kv "Uptime:" "$(uptime -p 2>/dev/null || uptime)"
echo ""
echo " Disk usage (df -h):"
df -h --output=source,size,used,avail,pcent,target 2>/dev/null | grep -v tmpfs | grep -v udev | sed 's/^/ /'
echo ""
echo " Memory (free -h):"
free -h 2>/dev/null | sed 's/^/ /' || vm_stat 2>/dev/null | sed 's/^/ /'
# =============================================================================
h1 "2. DOCKER & COMPOSE"
# =============================================================================
if command -v docker &>/dev/null; then
ok "Docker installed"
kv "Docker version:" "$(docker --version)"
kv "Compose version:" "$(docker compose version 2>/dev/null || docker-compose --version 2>/dev/null || echo 'not found')"
else
fail "Docker not found on PATH"
fi
# =============================================================================
h1 "3. REPOSITORY STATE"
# =============================================================================
if [ -d "$REMOTE_PATH/.git" ]; then
cd "$REMOTE_PATH"
ok "Repo found at $REMOTE_PATH"
kv "Branch:" "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
kv "Latest commit:" "$(git log -1 --format='%h %s (%cr)' 2>/dev/null)"
kv "Commit author:" "$(git log -1 --format='%an <%ae>' 2>/dev/null)"
kv "Remote origin:" "$(git remote get-url origin 2>/dev/null)"
DIRTY=$(git status --porcelain 2>/dev/null)
if [ -n "$DIRTY" ]; then
warn "Working tree has uncommitted changes:"
git status --short | sed 's/^/ /'
else
ok "Working tree clean"
fi
echo ""
echo " Recent commits (last 5):"
git log -5 --format=' %h %s (%cr)' 2>/dev/null
else
fail "No git repo found at $REMOTE_PATH"
fi
# =============================================================================
h1 "4. ENVIRONMENT FILE"
# =============================================================================
ENV_PATH="$REMOTE_PATH/$ENV_FILE"
if [ -f "$ENV_PATH" ]; then
ok "Env file: $ENV_PATH"
kv "Modified:" "$(stat -c '%y' "$ENV_PATH" 2>/dev/null | cut -d'.' -f1 || stat -f '%Sm' "$ENV_PATH" 2>/dev/null)"
kv "Permissions:" "$(stat -c '%a %U:%G' "$ENV_PATH" 2>/dev/null || stat -f '%Sp %Su:%Sg' "$ENV_PATH" 2>/dev/null)"
echo ""
echo " Env keys present (values redacted):"
grep -v '^#' "$ENV_PATH" | grep '=' | cut -d'=' -f1 | sort | sed 's/^/ /'
else
fail "Env file NOT found at $ENV_PATH"
fi
# =============================================================================
h1 "5. DOCKER STACK — CONTAINER STATUS"
# =============================================================================
cd "$REMOTE_PATH" 2>/dev/null || true
echo " All containers on this host:"
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}' | sed 's/^/ /'
echo ""
echo " Compose stack status ($COMPOSE_FILE):"
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps 2>/dev/null | sed 's/^/ /' || \
warn "Could not run docker compose ps (compose file or env file missing?)"
# Per-container inspection
for CNAME in "$BACKEND_CONTAINER" "$FRONTEND_CONTAINER" "$NGINX_CONTAINER" "$DB_CONTAINER"; do
STATUS=$(docker inspect --format='{{.State.Status}}' "$CNAME" 2>/dev/null || echo "missing")
HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' "$CNAME" 2>/dev/null || echo "missing")
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$CNAME" 2>/dev/null || echo "unknown")
STARTED=$(docker inspect --format='{{.State.StartedAt}}' "$CNAME" 2>/dev/null | cut -d'.' -f1 || echo "unknown")
echo ""
echo -e " ${BOLD}$CNAME${RESET}"
kv " Status:" "$STATUS"
kv " Health:" "$HEALTH"
kv " Image:" "$IMAGE"
kv " Started:" "$STARTED"
if [ "$STATUS" = "running" ]; then
MEM=$(docker stats "$CNAME" --no-stream --format '{{.MemUsage}}' 2>/dev/null || echo "n/a")
CPU=$(docker stats "$CNAME" --no-stream --format '{{.CPUPerc}}' 2>/dev/null || echo "n/a")
kv " CPU / Mem:" "$CPU / $MEM"
fi
done
# =============================================================================
h1 "6. DOCKER VOLUMES"
# =============================================================================
echo " Named volumes:"
docker volume ls --format 'table {{.Name}}\t{{.Driver}}\t{{.Mountpoint}}' | grep -i lean | sed 's/^/ /' || echo " (none matching lean)"
echo ""
echo " All volumes:"
docker volume ls --format ' {{.Name}}' | head -30
# Postgres data volume size
PG_MOUNT=$(docker volume inspect lean101-clients_clients_db_data --format '{{.Mountpoint}}' 2>/dev/null || \
docker volume inspect clients_db_data --format '{{.Mountpoint}}' 2>/dev/null || true)
if [ -n "$PG_MOUNT" ]; then
PG_SIZE=$(du -sh "$PG_MOUNT" 2>/dev/null | cut -f1 || echo "n/a")
kv " Postgres volume size:" "$PG_SIZE ($PG_MOUNT)"
fi
# =============================================================================
h1 "7. POSTGRESQL — DATABASE STATE"
# =============================================================================
DB_STATUS=$(docker inspect --format='{{.State.Status}}' "$DB_CONTAINER" 2>/dev/null || echo "missing")
if [ "$DB_STATUS" = "running" ]; then
ok "Database container is running"
PG_USER=$(docker exec "$DB_CONTAINER" printenv POSTGRES_USER 2>/dev/null || echo "lean101")
PG_DB=$(docker exec "$DB_CONTAINER" printenv POSTGRES_DB 2>/dev/null || echo "lean101")
echo ""
echo " PostgreSQL version:"
cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c "SELECT version();" | sed 's/^/ /'
echo ""
echo " Database size:"
cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" \
-c "SELECT pg_database.datname, pg_size_pretty(pg_database_size(pg_database.datname)) AS size FROM pg_database ORDER BY pg_database_size(pg_database.datname) DESC;" \
| sed 's/^/ /'
echo ""
echo " Tables and row counts:"
cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c "
SELECT
schemaname,
relname AS table_name,
n_live_tup AS row_count,
pg_size_pretty(pg_total_relation_size(quote_ident(schemaname)||'.'||quote_ident(relname))) AS total_size
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
" | sed 's/^/ /'
echo ""
echo " Alembic migration state:"
cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c \
"SELECT version_num, is_current FROM alembic_version LEFT JOIN (SELECT true AS is_current) t ON true;" \
2>/dev/null | sed 's/^/ /' || \
cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c \
"SELECT version_num FROM alembic_version;" 2>/dev/null | sed 's/^/ /' || \
warn "alembic_version table not found or inaccessible"
echo ""
echo " Active connections:"
cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c "
SELECT count(*) AS total_connections,
sum(CASE WHEN state = 'active' THEN 1 ELSE 0 END) AS active
FROM pg_stat_activity
WHERE datname = '$PG_DB';
" | sed 's/^/ /'
else
fail "Database container status: $DB_STATUS — skipping DB checks"
fi
# =============================================================================
h1 "8. BACKEND — HEALTH & API"
# =============================================================================
if [ "$(docker inspect --format='{{.State.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)" = "running" ]; then
echo " Health endpoint (internal):"
HEALTH_RESP=$(cexec "$BACKEND_CONTAINER" python -c \
"import urllib.request, json; r=urllib.request.urlopen('http://127.0.0.1:8000/health'); print(r.read().decode())" 2>/dev/null || echo "failed")
echo " $HEALTH_RESP"
echo ""
echo " Python / package versions inside backend container:"
cexec "$BACKEND_CONTAINER" python --version 2>&1 | sed 's/^/ /'
cexec "$BACKEND_CONTAINER" pip show fastapi sqlalchemy alembic psycopg 2>/dev/null | \
grep -E '^(Name|Version):' | sed 's/^/ /'
echo ""
echo " DATABASE_URL in backend (secret masked):"
cexec "$BACKEND_CONTAINER" printenv DATABASE_URL | \
sed 's|://[^:]*:[^@]*@|://***:***@|g' | sed 's/^/ /'
else
fail "Backend container not running — skipping API checks"
fi
# =============================================================================
h1 "9. NGINX — CONFIGURATION"
# =============================================================================
if [ "$(docker inspect --format='{{.State.Status}}' "$NGINX_CONTAINER" 2>/dev/null)" = "running" ]; then
ok "Nginx container is running"
echo ""
echo " nginx -t output:"
cexec "$NGINX_CONTAINER" nginx -t 2>&1 | sed 's/^/ /'
echo ""
echo " Active listening ports (nginx container):"
docker exec "$NGINX_CONTAINER" sh -c 'cat /etc/nginx/conf.d/default.conf 2>/dev/null | grep -E "listen|server_name|proxy_pass"' | sed 's/^/ /' || true
echo ""
echo " Host port binding:"
docker port "$NGINX_CONTAINER" | sed 's/^/ /'
else
warn "Nginx container not running"
fi
# =============================================================================
h1 "10. RECENT CONTAINER LOGS (last 30 lines each)"
# =============================================================================
for CNAME in "$BACKEND_CONTAINER" "$FRONTEND_CONTAINER" "$NGINX_CONTAINER" "$DB_CONTAINER"; do
STATUS=$(docker inspect --format='{{.State.Status}}' "$CNAME" 2>/dev/null || echo "missing")
echo ""
echo -e " ${BOLD}$CNAME${RESET} [$STATUS]"
if [ "$STATUS" != "missing" ]; then
docker logs "$CNAME" --tail=30 2>&1 | sed 's/^/ /'
fi
done
# =============================================================================
h1 "11. HOST NETWORK & FIREWALL"
# =============================================================================
echo " Listening ports on host (ss -tlnp):"
ss -tlnp 2>/dev/null | sed 's/^/ /' || \
netstat -tlnp 2>/dev/null | sed 's/^/ /' || \
warn "Neither ss nor netstat available"
echo ""
echo " UFW status:"
ufw status 2>/dev/null | sed 's/^/ /' || warn "UFW not available"
echo ""
echo " Docker network list:"
docker network ls | sed 's/^/ /'
# =============================================================================
h1 "12. COMPOSE FILE DIFF (local vs what would deploy)"
# =============================================================================
echo " Compose file on server ($COMPOSE_FILE):"
if [ -f "$REMOTE_PATH/$COMPOSE_FILE" ]; then
kv " Modified:" "$(stat -c '%y' "$REMOTE_PATH/$COMPOSE_FILE" 2>/dev/null | cut -d'.' -f1)"
kv " Size:" "$(wc -l < "$REMOTE_PATH/$COMPOSE_FILE") lines"
ok "File exists"
else
fail "$COMPOSE_FILE not found at $REMOTE_PATH"
fi
# =============================================================================
sep
echo -e "${BOLD} Check complete — paste this output into Claude for analysis.${RESET}"
sep
echo ""
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "data-entry-app-frontend", "name": "data-entry-app-frontend",
"version": "0.2.0", "version": "1.5.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js dev",
"build": "vite build", "build": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js build",
"preview": "vite preview", "preview": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js preview",
"test": "vitest run" "test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,46 @@
const childProcess = require('node:child_process');
const originalExec = childProcess.exec;
childProcess.exec = function patchedExec(command, options, callback) {
const normalizedCommand = typeof command === 'string' ? command.trim().toLowerCase() : '';
try {
return originalExec.call(this, command, options, callback);
} catch (error) {
if (normalizedCommand === 'net use' && error && error.code === 'EPERM') {
const cb =
typeof options === 'function'
? options
: typeof callback === 'function'
? callback
: null;
if (cb) {
process.nextTick(() => cb(error, '', ''));
}
return {
pid: undefined,
killed: false,
kill() {
return false;
},
on() {
return this;
},
once() {
return this;
},
emit() {
return false;
},
removeListener() {
return this;
}
};
}
throw error;
}
};
+30
View File
@@ -224,6 +224,34 @@ async function request<T>(
} }
} }
async function requestBlob(
path: string,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<Blob> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
});
if (!response.ok) {
let message = 'Request failed';
try {
const body = (await response.json()) as { detail?: string };
message = body.detail ?? message;
} catch {
message = response.statusText || message;
}
throw new Error(message);
}
return await response.blob();
} catch (error) {
throw normalizeRequestError(error);
}
}
export const api = { export const api = {
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher), rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher), mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
@@ -234,6 +262,8 @@ export const api = {
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher), cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) => mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher), request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) => previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorPreview>('/api/mix-calculator/preview', { request<MixCalculatorPreview>('/api/mix-calculator/preview', {
method: 'POST', method: 'POST',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,434 @@
<script lang="ts">
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
let {
session,
generatedAt = null,
showGeneratedStamp = true
}: {
session: MixCalculatorPreview | MixCalculatorSession;
generatedAt?: string | null;
showGeneratedStamp?: boolean;
} = $props();
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: undefined
}).format(new Date(value));
}
function formatTimestamp(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function formatNumber(value: number, digits = 2) {
return value.toFixed(digits);
}
function hasSessionNumber(value: MixCalculatorPreview | MixCalculatorSession): value is MixCalculatorSession {
return 'session_number' in value;
}
const sessionNumber = $derived(hasSessionNumber(session) ? session.session_number : null);
const issuedAt = $derived(generatedAt ?? new Date().toISOString());
const blendTotal = $derived(session.lines.reduce((sum, line) => sum + line.mix_percentage, 0));
</script>
<article class="print-document">
<header class="hero">
<div class="hero-copy">
<div class="hero-kicker">
<span>Mix Calculator</span>
{#if sessionNumber}
<strong>{sessionNumber}</strong>
{/if}
</div>
<h1>{session.product_name}</h1>
<p>{session.client_name} · {session.mix_name}</p>
</div>
<div class="hero-side">
<div>
<span>Mix date</span>
<strong>{formatDate(session.mix_date)}</strong>
</div>
<div>
<span>Prepared by</span>
<strong>{session.prepared_by_name}</strong>
</div>
<div>
<span>Status</span>
<strong>{session.status}</strong>
</div>
</div>
</header>
<section class="summary-band" aria-label="Session summary">
<article>
<span>Batch size</span>
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
</article>
<article>
<span>Total output</span>
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
</article>
<article>
<span>Bags</span>
<strong>{formatNumber(session.total_bags, 2)}</strong>
</article>
<article>
<span>Unit pack</span>
<strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong>
</article>
</section>
<section class="detail-grid">
<article class="detail-card">
<span>Mix source</span>
<strong>{session.mix_name}</strong>
<p>Saved against {session.product_unit_of_measure} units.</p>
</article>
<article class="detail-card">
<span>Composition</span>
<strong>{formatNumber(blendTotal, 2)}%</strong>
<p>{session.lines.length} raw material{session.lines.length === 1 ? '' : 's'} in the blend.</p>
</article>
{#if showGeneratedStamp}
<article class="detail-card">
<span>Generated</span>
<strong>{formatTimestamp(issuedAt)}</strong>
<p>Prepared for print or PDF export.</p>
</article>
{/if}
</section>
{#if session.notes}
<section class="callout notes">
<span>Notes</span>
<p>{session.notes}</p>
</section>
{/if}
{#if session.warnings.length}
<section class="callout warning">
<span>Warnings</span>
<ul>
{#each session.warnings as warning}
<li>{warning}</li>
{/each}
</ul>
</section>
{/if}
<section class="composition-card">
<div class="section-heading">
<div>
<span>Required Raw Materials</span>
<h2>Blend composition</h2>
</div>
<p>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</p>
</div>
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each session.lines as line}
<tr>
<td>
<strong>{line.raw_material_name}</strong>
</td>
<td>{formatNumber(line.mix_percentage, 2)}%</td>
<td>{formatNumber(line.required_kg, 2)}kg</td>
<td>{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</article>
<style>
:global(:root) {
--print-page-width: 210mm;
--print-page-height: 297mm;
--print-page-padding-x: 14mm;
--print-page-padding-y: 15mm;
}
h1,
h2,
p,
ul {
margin: 0;
}
.print-document {
display: grid;
gap: 1.4rem;
width: min(100%, var(--print-page-width));
min-height: var(--print-page-height);
margin: 0 auto;
padding: var(--print-page-padding-y) var(--print-page-padding-x);
border: 1px solid #dbe4de;
border-radius: 0.8rem;
background:
radial-gradient(circle at top right, rgba(21, 128, 61, 0.08), transparent 22rem),
linear-gradient(180deg, #fff 0%, #fbfcfb 100%);
color: #21312a;
box-shadow:
0 28px 48px rgba(21, 33, 26, 0.08),
0 0 0 1px rgba(219, 228, 222, 0.55);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 17rem;
gap: 1.25rem;
align-items: start;
padding-bottom: 1.3rem;
border-bottom: 1px solid color-mix(in srgb, var(--line) 88%, #dce7df);
}
.hero-kicker {
display: inline-flex;
align-items: center;
gap: 0.65rem;
margin-bottom: 0.75rem;
color: #62736b;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero-kicker strong {
padding: 0.36rem 0.55rem;
border: 1px solid #d5dfd9;
border-radius: 999px;
color: #214233;
font-size: 0.68rem;
letter-spacing: 0.08em;
}
.hero h1 {
max-width: 11ch;
font-size: clamp(2rem, 4vw, 3.3rem);
line-height: 0.96;
letter-spacing: -0.05em;
}
.hero-copy p,
.section-heading p,
.detail-card p {
color: #6b7a73;
}
.hero-copy p {
margin-top: 0.7rem;
font-size: 1rem;
}
.hero-side,
.summary-band,
.detail-grid {
display: grid;
gap: 0.8rem;
}
.hero-side div,
.summary-band article,
.detail-card {
display: grid;
gap: 0.25rem;
}
.hero-side span,
.summary-band span,
.detail-card span,
.callout span,
th,
.section-heading span {
color: #6b7a73;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero-side strong,
.detail-card strong {
font-size: 1rem;
}
.summary-band {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.summary-band article {
min-height: 6.2rem;
padding: 1rem 1.05rem;
border: 1px solid #dfe7e2;
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.92);
}
.summary-band strong {
margin-top: auto;
font-size: clamp(1.4rem, 2.4vw, 2rem);
letter-spacing: -0.04em;
}
.detail-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detail-card {
min-height: 7rem;
padding: 1rem 1.05rem;
border-radius: 1.15rem;
background: #f6f9f7;
}
.callout {
display: grid;
gap: 0.55rem;
padding: 1rem 1.1rem;
border-radius: 1.15rem;
break-inside: avoid;
}
.callout.notes {
background: #f6f9f7;
border: 1px solid #dfe7e2;
}
.callout.warning {
background: #fff7ea;
border: 1px solid #f0cf97;
color: #82561b;
}
.callout ul {
padding-left: 1rem;
}
.composition-card {
display: grid;
gap: 0.9rem;
break-inside: avoid;
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
}
.section-heading h2 {
margin-top: 0.32rem;
font-size: 1.5rem;
letter-spacing: -0.04em;
}
table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.8);
border: 1px solid #dfe7e2;
border-radius: 1.2rem;
overflow: hidden;
}
th,
td {
padding: 0.95rem 1rem;
text-align: left;
border-bottom: 1px solid #e6ede9;
}
thead {
display: table-header-group;
}
tr,
td,
th {
break-inside: avoid;
}
tbody tr:last-child td {
border-bottom: none;
}
td strong {
font-size: 0.98rem;
font-weight: 700;
color: #203128;
}
@media (max-width: 900px) {
.hero,
.summary-band,
.detail-grid {
grid-template-columns: 1fr;
}
.section-heading {
flex-direction: column;
align-items: start;
}
}
@media print {
:global(html),
:global(body) {
width: var(--print-page-width);
min-height: var(--print-page-height);
margin: 0;
background: #fff;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.print-document {
width: var(--print-page-width);
min-height: var(--print-page-height);
margin: 0;
padding: var(--print-page-padding-y) var(--print-page-padding-x);
border: none;
border-radius: 0;
background: #fff;
color: #1e2622;
box-shadow: none;
}
.summary-band article,
.detail-card,
table,
.callout {
border-color: #d5ddd8;
background: #fff;
}
.callout.warning {
background: #fff8ef;
}
@page {
size: A4 portrait;
margin: 0;
}
}
</style>
@@ -1,29 +1,25 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import type { MixCalculatorSession } from '$lib/types'; import type { MixCalculatorSession } from '$lib/types';
let { session }: { session: MixCalculatorSession } = $props(); let { session }: { session: MixCalculatorSession } = $props();
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: undefined
}).format(new Date(value));
}
function formatTimestamp(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function formatNumber(value: number, digits = 2) {
return value.toFixed(digits);
}
const printableTitle = $derived( const printableTitle = $derived(
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_') `MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
); );
async function downloadPdf() {
const blob = await api.mixCalculatorSessionPdf(session.id);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${printableTitle}.pdf`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -33,118 +29,21 @@
<section class="print-page"> <section class="print-page">
<div class="print-toolbar"> <div class="print-toolbar">
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a> <a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
<button class="primary-button" type="button" onclick={() => window.print()}>Print / Save PDF</button> <button class="primary-button" type="button" onclick={() => window.print()}>Print / Save PDF</button>
</div> </div>
<article class="sheet"> <MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
<header class="sheet-header">
<div>
<p class="eyebrow">Mix Calculator</p>
<h1>{session.session_number}</h1>
<p>Generated from the saved session snapshot without re-reading live product or recipe data.</p>
</div>
<div class="sheet-meta">
<div>
<span>Generated</span>
<strong>{formatTimestamp(new Date().toISOString())}</strong>
</div>
<div>
<span>Status</span>
<strong>{session.status}</strong>
</div>
</div>
</header>
<section class="summary-grid">
<div>
<span>Date</span>
<strong>{formatDate(session.mix_date)}</strong>
</div>
<div>
<span>Client</span>
<strong>{session.client_name}</strong>
</div>
<div>
<span>Product</span>
<strong>{session.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<strong>{session.mix_name}</strong>
</div>
<div>
<span>Batch size</span>
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
</div>
<div>
<span>Total bags</span>
<strong>{formatNumber(session.total_bags, 2)}</strong>
</div>
<div>
<span>Total kilograms</span>
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
</div>
<div>
<span>Prepared by</span>
<strong>{session.prepared_by_name}</strong>
</div>
</section>
{#if session.notes}
<section class="notes-card">
<h2>Session notes</h2>
<p>{session.notes}</p>
</section>
{/if}
{#if session.warnings.length}
<section class="warning-card">
<h2>Warnings</h2>
{#each session.warnings as warning}
<p>{warning}</p>
{/each}
</section>
{/if}
<section class="table-card">
<div class="table-header">
<h2>Required raw materials</h2>
<span>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</span>
</div>
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each session.lines as line}
<tr>
<td>{line.raw_material_name}</td>
<td>{formatNumber(line.mix_percentage, 2)}%</td>
<td>{formatNumber(line.required_kg, 2)}kg</td>
<td>{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</article>
</section> </section>
<style> <style>
h1,
h2,
p {
margin: 0;
}
.print-page { .print-page {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
justify-items: center;
padding: 1.5rem 1rem 2.5rem;
background:
linear-gradient(180deg, #eef4f0 0%, #e6eee9 100%);
} }
.print-toolbar { .print-toolbar {
@@ -176,127 +75,13 @@
color: #304038; color: #304038;
} }
.sheet {
width: min(960px, 100%);
margin: 0 auto;
padding: 2rem;
border: 1px solid var(--line);
border-radius: 1.5rem;
background: #fff;
box-shadow: var(--shadow);
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sheet-header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--line);
}
.sheet-header h1 {
margin: 0.3rem 0 0.45rem;
font-size: clamp(2rem, 4vw, 2.6rem);
}
.sheet-header p:last-child,
.sheet-meta span,
.summary-grid span,
.table-header span {
color: var(--muted);
}
.sheet-meta {
min-width: 14rem;
display: grid;
gap: 0.9rem;
}
.sheet-meta div,
.summary-grid div {
display: grid;
gap: 0.16rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
padding: 1.35rem 0;
}
.notes-card,
.warning-card,
.table-card {
margin-top: 1rem;
}
.notes-card,
.warning-card {
padding: 1rem;
border-radius: 1rem;
}
.notes-card {
background: var(--panel-soft);
}
.warning-card {
background: #fff6e6;
color: #8b5b1e;
}
.warning-card h2,
.notes-card h2,
.table-header h2 {
margin-bottom: 0.45rem;
font-size: 1rem;
}
.table-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.88rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.sheet-header, .print-toolbar {
.table-header { justify-content: stretch;
flex-direction: column;
} }
.summary-grid { .print-toolbar > :global(*) {
grid-template-columns: repeat(2, minmax(0, 1fr)); flex: 1 1 auto;
} }
} }
@@ -305,17 +90,13 @@
background: #fff; background: #fff;
} }
.print-page {
padding: 0;
background: #fff;
}
.print-toolbar { .print-toolbar {
display: none; display: none;
} }
.sheet {
width: 100%;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
box-shadow: none;
}
} }
</style> </style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import { featureFlags } from '$lib/features'; import { featureFlags } from '$lib/features';
import { clientSession, hasModuleAccess } from '$lib/session'; import { clientSession, hasModuleAccess } from '$lib/session';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
@@ -10,6 +11,8 @@
MixCalculatorPreview, MixCalculatorPreview,
MixCalculatorSession MixCalculatorSession
} from '$lib/types'; } from '$lib/types';
import MixCalculatorPreviewModal from './MixCalculatorPreviewModal.svelte';
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props(); let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
@@ -51,9 +54,9 @@
let notes = $state(initialNotesValue()); let notes = $state(initialNotesValue());
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue()); let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state(''); let formError = $state('');
let formSuccess = $state('');
let previewLoading = $state(false); let previewLoading = $state(false);
let saveLoading = $state(false); let saveLoading = $state(false);
let previewModalOpen = $state(false);
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit')); const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
const isExistingSession = $derived(initialSession !== null); const isExistingSession = $derived(initialSession !== null);
@@ -103,12 +106,6 @@
} }
}); });
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium'
}).format(new Date(value));
}
function formatNumber(value: number | null | undefined, digits = 2) { function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return 'N/A'; return 'N/A';
@@ -119,7 +116,6 @@
function buildPayload(): MixCalculatorCreateInput | null { function buildPayload(): MixCalculatorCreateInput | null {
formError = ''; formError = '';
formSuccess = '';
const numericBatchSize = Number(batchSizeKg); const numericBatchSize = Number(batchSizeKg);
if (!mixDate) { if (!mixDate) {
@@ -184,7 +180,6 @@
notes = ''; notes = '';
preview = null; preview = null;
formError = ''; formError = '';
formSuccess = '';
} }
function printPreview() { function printPreview() {
@@ -193,6 +188,37 @@
} }
} }
async function downloadSessionPdf(sessionId: number) {
const tid = toast.loading('Generating PDF…');
try {
const blob = await api.mixCalculatorSessionPdf(sessionId);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `mix_calculator_${sessionId}.pdf`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
toast.dismiss(tid);
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to generate PDF.');
}
}
function openPreviewModal() {
if (!preview) {
return;
}
previewModalOpen = true;
}
function closePreviewModal() {
previewModalOpen = false;
}
async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') { async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') {
const payload = buildPayload(); const payload = buildPayload();
if (!payload) { if (!payload) {
@@ -237,11 +263,12 @@
{/if} {/if}
{#if initialSession} {#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a> <a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
<button class="secondary-button" type="button" onclick={() => downloadSessionPdf(initialSession.id)}>Download PDF</button>
{/if} {/if}
</section> </section>
{/if} {/if}
<section class="workspace-grid"> <section class="editor-grid">
<article class="form-card"> <article class="form-card">
<div class="section-header"> <div class="section-header">
<div> <div>
@@ -259,9 +286,6 @@
{#if formError} {#if formError}
<p class="message error">{formError}</p> <p class="message error">{formError}</p>
{/if} {/if}
{#if formSuccess}
<p class="message success">{formSuccess}</p>
{/if}
<div class="field-grid"> <div class="field-grid">
<label> <label>
@@ -337,6 +361,10 @@
</button> </button>
{/if} {/if}
{:else} {:else}
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={openPreviewModal}>
<span>Preview</span>
</button>
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}> <button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}>
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span> <span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
<span>Print</span> <span>Print</span>
@@ -351,202 +379,25 @@
{/if} {/if}
</article> </article>
<article class="result-card"> <MixCalculatorResultsPanel
<div class="section-header"> preview={preview}
<div> sessionNumber={initialSession?.session_number ?? null}
<h3>Calculated Output</h3> />
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
</div>
{#if initialSession}
<div class="session-chip">
<span>Session</span>
<strong>{initialSession.session_number}</strong>
</div>
{/if}
</div>
{#if preview}
<div class="metric-row">
<article class="metric-card">
<span>Total kg</span>
<strong>{formatNumber(preview.total_kg, 2)}</strong>
<p>Scaled batch size</p>
</article>
<article class="metric-card">
<span>Total bags</span>
<strong>{formatNumber(preview.total_bags, 2)}</strong>
<p>{preview.product_unit_of_measure}</p>
</article>
<article class="metric-card">
<span>Prepared by</span>
<strong>{preview.prepared_by_name}</strong>
<p>{formatDate(preview.mix_date)}</p>
</article>
</div>
{#if preview.warnings.length}
<div class="warning-stack">
{#each preview.warnings as warning}
<p>{warning}</p>
{/each}
</div>
{/if}
<div class="summary-grid">
<div>
<span>Client</span>
<strong>{preview.client_name}</strong>
</div>
<div>
<span>Product</span>
<strong>{preview.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<strong>{preview.mix_name}</strong>
</div>
<div>
<span>Unit size</span>
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each preview.lines as line}
<tr>
<td data-label="Raw material">
<strong>{line.raw_material_name}</strong>
</td>
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
<td data-label="Unit">{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="empty-state">
<div class="empty-shimmer-metrics">
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
</div>
<div class="empty-state-copy">
<div class="empty-icon" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
</div>
<div class="empty-shimmer-rows">
{#each [1,2,3,4,5] as _}
<div class="shimmer-row">
<div class="shimmer-line wide"></div>
<div class="shimmer-line medium"></div>
<div class="shimmer-line medium"></div>
<div class="shimmer-line short"></div>
</div>
{/each}
</div>
</div>
{/if}
</article>
</section> </section>
{#if preview} {#if preview}
{#if previewModalOpen}
<MixCalculatorPreviewModal
preview={preview}
sessionId={initialSession?.id ?? null}
onClose={closePreviewModal}
onPrint={printPreview}
onDownloadPdf={downloadSessionPdf}
/>
{/if}
<section class="print-only" aria-hidden="true"> <section class="print-only" aria-hidden="true">
<article class="print-sheet"> <MixCalculatorPrintDocument session={preview} />
<header class="print-header">
<div>
<p class="print-eyebrow">Mix Calculator</p>
<h1>{preview.product_name}</h1>
<p class="print-subtitle">{preview.client_name} · {preview.mix_name}</p>
</div>
<div class="print-meta">
<div>
<span>Mix date</span>
<strong>{formatDate(preview.mix_date)}</strong>
</div>
<div>
<span>Prepared by</span>
<strong>{preview.prepared_by_name}</strong>
</div>
</div>
</header>
<section class="print-summary">
<div>
<span>Batch size</span>
<strong>{formatNumber(preview.batch_size_kg, 2)}kg</strong>
</div>
<div>
<span>Total kilograms</span>
<strong>{formatNumber(preview.total_kg, 2)}kg</strong>
</div>
<div>
<span>Total bags</span>
<strong>{formatNumber(preview.total_bags, 2)}</strong>
</div>
<div>
<span>Unit size</span>
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
</div>
</section>
{#if preview.notes}
<section class="print-notes">
<h2>Notes</h2>
<p>{preview.notes}</p>
</section>
{/if}
{#if preview.warnings.length}
<section class="print-warnings">
<h2>Warnings</h2>
{#each preview.warnings as warning}
<p>{warning}</p>
{/each}
</section>
{/if}
<section class="print-table">
<div class="print-table-header">
<h2>Required raw materials</h2>
<span>{preview.product_unit_of_measure} · {formatNumber(preview.product_unit_size_kg, 2)}kg per unit</span>
</div>
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each preview.lines as line}
<tr>
<td>{line.raw_material_name}</td>
<td>{formatNumber(line.mix_percentage, 2)}%</td>
<td>{formatNumber(line.required_kg, 2)}kg</td>
<td>{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</article>
</section> </section>
{/if} {/if}
{/if} {/if}
@@ -564,22 +415,10 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.eyebrow-icon {
display: inline-block;
width: 0.95rem;
height: 0.95rem;
background-color: currentColor;
-webkit-mask: var(--button-icon-url) center / contain no-repeat;
mask: var(--button-icon-url) center / contain no-repeat;
} }
.page-actions, .page-actions,
.workspace-grid { .editor-grid {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
} }
@@ -590,30 +429,11 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.page-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.page-intro h2 {
margin: 0.3rem 0 0.45rem;
max-width: 16ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.section-header p, .section-header p,
.metric-card p, .calculation-note span {
.summary-grid span,
.calculation-note span,
.empty-state span {
color: var(--muted); color: var(--muted);
} }
.header-actions,
.action-row { .action-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -621,15 +441,13 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.workspace-grid { .editor-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: 1rem; gap: 1rem;
} }
.form-card, .form-card,
.result-card,
.metric-card,
.locked-card { .locked-card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1.3rem; border-radius: 1.3rem;
@@ -638,7 +456,6 @@
} }
.form-card, .form-card,
.result-card,
.locked-card { .locked-card {
padding: 1.2rem; padding: 1.2rem;
} }
@@ -665,8 +482,7 @@
font-weight: 700; font-weight: 700;
} }
.product-pill, .product-pill {
.session-chip {
display: grid; display: grid;
gap: 0.14rem; gap: 0.14rem;
padding: 0.72rem 0.82rem; padding: 0.72rem 0.82rem;
@@ -675,8 +491,7 @@
background: var(--panel-soft); background: var(--panel-soft);
} }
.product-pill span, .product-pill span {
.session-chip span {
color: var(--muted); color: var(--muted);
font-size: 0.78rem; font-size: 0.78rem;
text-transform: uppercase; text-transform: uppercase;
@@ -718,27 +533,15 @@
resize: vertical; resize: vertical;
} }
.calculation-note,
.warning-stack,
.empty-state {
margin-top: 1rem;
padding: 0.92rem;
border-radius: 1rem;
}
.calculation-note { .calculation-note {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
margin-top: 1rem;
padding: 0.92rem;
border-radius: 1rem;
background: var(--panel-soft); background: var(--panel-soft);
} }
.warning-stack {
display: grid;
gap: 0.45rem;
background: #fff6e6;
color: #8b5b1e;
}
.message { .message {
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
padding: 0.75rem 0.85rem; padding: 0.75rem 0.85rem;
@@ -751,11 +554,6 @@
color: #b2463f; color: #b2463f;
} }
.message.success {
background: var(--green-soft);
color: var(--green-deep);
}
.action-row { .action-row {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -820,259 +618,20 @@
opacity: 0.7; opacity: 0.7;
} }
.metric-row,
.summary-grid {
display: grid;
gap: 0.85rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 1rem;
}
.metric-card {
padding: 1rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.45rem 0 0.18rem;
font-size: 1.45rem;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 1rem;
}
.summary-grid div {
padding: 0.88rem 0.92rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.summary-grid span {
display: block;
margin-bottom: 0.2rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
min-width: 30rem;
border-collapse: collapse;
}
th,
td {
padding: 0.9rem 0.85rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.empty-state {
display: flex;
flex-direction: column;
gap: 0;
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--line);
}
.empty-shimmer-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
}
.shimmer-metric {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 0.85rem;
background: var(--panel);
}
.empty-state-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1.5rem;
text-align: center;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.empty-state-copy strong {
font-size: 0.98rem;
font-weight: 700;
color: var(--text);
}
.empty-state-copy span {
max-width: 26rem;
font-size: 0.84rem;
color: var(--muted);
line-height: 1.5;
}
.empty-icon {
display: flex;
align-items: flex-end;
gap: 0.28rem;
height: 2.2rem;
margin-bottom: 0.35rem;
}
.empty-icon span {
width: 0.38rem;
border-radius: 999px 999px 0 0;
background: var(--color-border);
animation: bar-pulse 1.6s ease-in-out infinite;
}
.empty-icon span:nth-child(1) { height: 60%; animation-delay: 0s; }
.empty-icon span:nth-child(2) { height: 100%; animation-delay: 0.2s; }
.empty-icon span:nth-child(3) { height: 40%; animation-delay: 0.4s; }
@keyframes bar-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.empty-shimmer-rows {
display: flex;
flex-direction: column;
background: var(--panel-soft);
}
.shimmer-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 0.75fr;
gap: 1rem;
align-items: center;
padding: 0.78rem 1rem;
border-bottom: 1px solid var(--line);
}
.shimmer-row:last-child {
border-bottom: none;
}
.shimmer-line {
height: 0.7rem;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--color-border) 25%,
color-mix(in srgb, var(--color-border) 40%, white) 50%,
var(--color-border) 75%
);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
}
.shimmer-line.short { width: 40%; }
.shimmer-line.medium { width: 65%; }
.shimmer-line.wide { width: 90%; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 980px) { @media (max-width: 980px) {
.workspace-grid { .editor-grid {
grid-template-columns: 1fr;
}
.metric-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.page-intro,
.section-header { .section-header {
flex-direction: column; flex-direction: column;
} }
.field-grid, .field-grid {
.summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.75rem;
}
tbody tr {
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
overflow: hidden;
}
tbody td {
border-bottom: 1px solid var(--line);
}
tbody td:last-child {
border-bottom: none;
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.24rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
} }
.print-only { .print-only {
@@ -1098,127 +657,10 @@
display: block; display: block;
position: absolute; position: absolute;
inset: 0; inset: 0;
padding: 1.4cm; padding: 0;
background: #fff; background: #fff;
color: #1a2421; color: #1a2421;
font-family: inherit; font-family: inherit;
} }
.print-sheet {
width: 100%;
}
.print-header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #cbd6cf;
}
.print-header h1 {
margin: 0.25rem 0 0.3rem;
font-size: 1.7rem;
}
.print-eyebrow {
color: #5f6f67;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0;
}
.print-subtitle {
color: #5f6f67;
margin: 0;
}
.print-meta {
display: grid;
gap: 0.55rem;
min-width: 12rem;
}
.print-meta div,
.print-summary div {
display: grid;
gap: 0.1rem;
}
.print-meta span,
.print-summary span,
.print-table-header span {
color: #5f6f67;
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.print-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.85rem;
padding: 1rem 0;
border-bottom: 1px solid #cbd6cf;
}
.print-notes,
.print-warnings {
margin-top: 0.85rem;
padding: 0.75rem 0.9rem;
border: 1px solid #cbd6cf;
border-radius: 0.5rem;
}
.print-warnings {
border-color: #d8a76b;
background: #fff6e6;
color: #8b5b1e;
}
.print-notes h2,
.print-warnings h2,
.print-table-header h2 {
margin: 0 0 0.35rem;
font-size: 0.95rem;
}
.print-table {
margin-top: 1rem;
}
.print-table-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.print-table table {
width: 100%;
border-collapse: collapse;
}
.print-table th,
.print-table td {
padding: 0.55rem 0.5rem;
text-align: left;
border-bottom: 1px solid #cbd6cf;
font-size: 0.92rem;
}
.print-table th {
color: #5f6f67;
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@page {
margin: 1cm;
}
} }
</style> </style>
@@ -0,0 +1,165 @@
<script lang="ts">
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
let {
preview,
sessionId = null,
onClose,
onPrint,
onDownloadPdf
}: {
preview: MixCalculatorPreview | MixCalculatorSession;
sessionId?: number | null;
onClose: () => void;
onPrint: () => void;
onDownloadPdf: (sessionId: number) => void;
} = $props();
</script>
<div class="preview-modal-backdrop" role="presentation" onclick={onClose}>
<div
class="preview-modal"
role="dialog"
aria-modal="true"
aria-label="Print preview"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
>
<div class="preview-modal-toolbar">
<div>
<p class="preview-modal-kicker">Print Preview</p>
<h3>{preview.product_name}</h3>
</div>
<div class="preview-modal-actions">
<button class="secondary-button" type="button" onclick={onClose}>Close</button>
{#if sessionId}
<a class="secondary-button" href={`/mix-calculator/${sessionId}/print`}>Open page</a>
<button class="secondary-button" type="button" onclick={() => onDownloadPdf(sessionId)}>Download PDF</button>
{/if}
<button class="primary-button" type="button" onclick={onPrint}>Print / Save PDF</button>
</div>
</div>
<div class="preview-sheet-frame">
<div class="preview-sheet-scroll">
<MixCalculatorPrintDocument session={preview} />
</div>
</div>
</div>
</div>
<style>
h3,
p {
margin: 0;
}
.preview-modal-backdrop {
position: fixed;
inset: 0;
z-index: 70;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(17, 24, 20, 0.52);
backdrop-filter: blur(12px);
}
.preview-modal {
display: grid;
gap: 1rem;
width: min(1180px, 100%);
max-height: calc(100vh - 2rem);
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 1.6rem;
background:
linear-gradient(180deg, rgba(248, 250, 248, 0.96), rgba(240, 246, 242, 0.96));
}
.preview-modal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.preview-modal-kicker {
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.preview-modal-toolbar h3 {
margin-top: 0.24rem;
font-size: 1.35rem;
letter-spacing: -0.04em;
}
.preview-modal-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.preview-sheet-frame {
min-height: 0;
display: grid;
place-items: start center;
padding: 1.1rem;
border-radius: 1.35rem;
background:
linear-gradient(135deg, #dfe8e2 0%, #eef3ef 45%, #d7e2db 100%);
}
.preview-sheet-scroll {
max-height: calc(100vh - 12rem);
overflow: auto;
width: 100%;
padding-right: 0.3rem;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
border: 1px solid var(--line-strong);
font-weight: 600;
cursor: pointer;
}
.primary-button {
border: none;
background: var(--color-brand);
color: #fff;
}
.secondary-button {
background: #fff;
color: #304038;
}
@media (max-width: 720px) {
.preview-modal-toolbar {
flex-direction: column;
align-items: start;
}
.preview-sheet-frame {
padding: 0.55rem;
}
}
</style>
@@ -0,0 +1,450 @@
<script lang="ts">
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
let {
preview,
sessionNumber = null
}: {
preview: MixCalculatorPreview | MixCalculatorSession | null;
sessionNumber?: string | null;
} = $props();
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium'
}).format(new Date(value));
}
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return value.toFixed(digits);
}
</script>
<article class="result-card">
<div class="section-header">
<div>
<h3>Calculated Output</h3>
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
</div>
{#if sessionNumber}
<div class="session-chip">
<span>Session</span>
<strong>{sessionNumber}</strong>
</div>
{/if}
</div>
{#if preview}
<div class="metric-row">
<article class="metric-card">
<span>Total kg</span>
<strong>{formatNumber(preview.total_kg, 2)}</strong>
<p>Scaled batch size</p>
</article>
<article class="metric-card">
<span>Total bags</span>
<strong>{formatNumber(preview.total_bags, 2)}</strong>
<p>{preview.product_unit_of_measure}</p>
</article>
<article class="metric-card">
<span>Prepared by</span>
<strong>{preview.prepared_by_name}</strong>
<p>{formatDate(preview.mix_date)}</p>
</article>
</div>
{#if preview.warnings.length}
<div class="warning-stack">
{#each preview.warnings as warning}
<p>{warning}</p>
{/each}
</div>
{/if}
<div class="summary-grid">
<div>
<span>Client</span>
<strong>{preview.client_name}</strong>
</div>
<div>
<span>Product</span>
<strong>{preview.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<strong>{preview.mix_name}</strong>
</div>
<div>
<span>Unit size</span>
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each preview.lines as line}
<tr>
<td data-label="Raw material">
<strong>{line.raw_material_name}</strong>
</td>
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
<td data-label="Unit">{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="empty-state">
<div class="empty-shimmer-metrics">
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
</div>
<div class="empty-state-copy">
<div class="empty-icon" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
</div>
<div class="empty-shimmer-rows">
{#each [1,2,3,4,5] as _}
<div class="shimmer-row">
<div class="shimmer-line wide"></div>
<div class="shimmer-line medium"></div>
<div class="shimmer-line medium"></div>
<div class="shimmer-line short"></div>
</div>
{/each}
</div>
</div>
{/if}
</article>
<style>
h3,
p {
margin: 0;
}
.section-header p,
.metric-card p,
.summary-grid span,
.empty-state span {
color: var(--muted);
}
.result-card,
.metric-card {
border: 1px solid var(--line);
border-radius: 1.3rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.result-card {
padding: 1.2rem;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 1.15rem;
font-weight: 700;
}
.session-chip {
display: grid;
gap: 0.14rem;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.92rem;
background: var(--panel-soft);
}
.session-chip span {
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.metric-row,
.summary-grid {
display: grid;
gap: 0.85rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 1rem;
}
.metric-card {
padding: 1rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.45rem 0 0.18rem;
font-size: 1.45rem;
}
.warning-stack {
display: grid;
gap: 0.45rem;
margin-top: 1rem;
padding: 0.92rem;
border-radius: 1rem;
background: #fff6e6;
color: #8b5b1e;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 1rem;
}
.summary-grid div {
padding: 0.88rem 0.92rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.summary-grid span {
display: block;
margin-bottom: 0.2rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
min-width: 30rem;
border-collapse: collapse;
}
th,
td {
padding: 0.9rem 0.85rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.empty-state {
display: flex;
flex-direction: column;
gap: 0;
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--line);
}
.empty-shimmer-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
}
.shimmer-metric {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 0.85rem;
background: var(--panel);
}
.empty-state-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1.5rem;
text-align: center;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.empty-state-copy strong {
font-size: 0.98rem;
font-weight: 700;
color: var(--text);
}
.empty-state-copy span {
max-width: 26rem;
font-size: 0.84rem;
line-height: 1.5;
}
.empty-icon {
display: flex;
align-items: flex-end;
gap: 0.28rem;
height: 2.2rem;
margin-bottom: 0.35rem;
}
.empty-icon span {
width: 0.38rem;
border-radius: 999px 999px 0 0;
background: var(--color-border);
animation: bar-pulse 1.6s ease-in-out infinite;
}
.empty-icon span:nth-child(1) { height: 60%; animation-delay: 0s; }
.empty-icon span:nth-child(2) { height: 100%; animation-delay: 0.2s; }
.empty-icon span:nth-child(3) { height: 40%; animation-delay: 0.4s; }
@keyframes bar-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.empty-shimmer-rows {
display: flex;
flex-direction: column;
background: var(--panel-soft);
}
.shimmer-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 0.75fr;
gap: 1rem;
align-items: center;
padding: 0.78rem 1rem;
border-bottom: 1px solid var(--line);
}
.shimmer-row:last-child {
border-bottom: none;
}
.shimmer-line {
height: 0.7rem;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--color-border) 25%,
color-mix(in srgb, var(--color-border) 40%, white) 50%,
var(--color-border) 75%
);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
}
.shimmer-line.short { width: 40%; }
.shimmer-line.medium { width: 65%; }
.shimmer-line.wide { width: 90%; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 980px) {
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.section-header {
flex-direction: column;
}
.summary-grid {
grid-template-columns: 1fr;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.75rem;
}
tbody tr {
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
overflow: hidden;
}
tbody td {
border-bottom: 1px solid var(--line);
}
tbody td:last-child {
border-bottom: none;
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.24rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
}
</style>
@@ -28,8 +28,6 @@
let mixVersion = $state(getInitialMix()?.version ?? 1); let mixVersion = $state(getInitialMix()?.version ?? 1);
let mixNotes = $state(getInitialMix()?.notes ?? ''); let mixNotes = $state(getInitialMix()?.notes ?? '');
let draftIngredients = $state<DraftIngredient[]>([]); let draftIngredients = $state<DraftIngredient[]>([]);
let feedback = $state('');
let errorMessage = $state('');
let isSaving = $state(false); let isSaving = $state(false);
function currency(value: number | null | undefined, digits = 2) { function currency(value: number | null | undefined, digits = 2) {
@@ -86,8 +84,6 @@
loadDraftFromMix(getInitialMix()); loadDraftFromMix(getInitialMix());
function resetDraft() { function resetDraft() {
feedback = '';
errorMessage = '';
loadDraftFromMix(savedMix); loadDraftFromMix(savedMix);
} }
@@ -318,14 +314,6 @@
</div> </div>
</section> </section>
{#if feedback}
<p class="feedback success">{feedback}</p>
{/if}
{#if errorMessage}
<p class="feedback error">{errorMessage}</p>
{/if}
<section class="metric-row"> <section class="metric-row">
<article class="metric-card"> <article class="metric-card">
<span>Live Draft Kg</span> <span>Live Draft Kg</span>
@@ -567,7 +555,6 @@
.locked-card, .locked-card,
.page-intro, .page-intro,
.feedback,
.metric-card, .metric-card,
.editor-card, .editor-card,
.summary-card { .summary-card {
@@ -579,7 +566,6 @@
.locked-card, .locked-card,
.page-intro, .page-intro,
.feedback,
.metric-row, .metric-row,
.editor-grid { .editor-grid {
margin-bottom: 1.12rem; margin-bottom: 1.12rem;
@@ -663,23 +649,6 @@
cursor: wait; cursor: wait;
} }
.feedback {
padding: 0.86rem 0.94rem;
font-weight: 600;
}
.feedback.success {
color: var(--green-deep);
border-color: #d8ecdf;
background: #f6fcf8;
}
.feedback.error {
color: #a03737;
border-color: #f0d9d9;
background: #fff8f8;
}
.metric-row, .metric-row,
.editor-grid, .editor-grid,
.meta-grid, .meta-grid,
@@ -841,76 +810,54 @@
.factor-list strong, .factor-list strong,
.healthy-card strong { .healthy-card strong {
display: block; display: block;
margin-bottom: 0.22rem; margin-bottom: 0.28rem;
font-size: 0.94rem; font-size: 0.96rem;
font-weight: 700; font-weight: 700;
} }
.warning-list article,
.healthy-card {
padding: 0.9rem 0.94rem;
border-radius: 0.92rem;
}
.warning-list article { .warning-list article {
border: 1px solid #f1e2c2; padding: 0.84rem 0.9rem;
background: #fffaf2; border: 1px solid #f0d8d8;
color: #8d5d21; border-radius: 0.92rem;
font-weight: 500; background: #fff7f7;
color: #9a4747;
font-size: 0.86rem;
font-weight: 600;
} }
.healthy-card { .healthy-card {
border: 1px solid var(--line); padding: 0.95rem 1rem;
background: var(--panel-soft); border: 1px solid #d9ecdf;
border-radius: 0.96rem;
background: #f6fcf8;
} }
@media (max-width: 1240px) { @media (max-width: 980px) {
.metric-row,
.editor-grid { .editor-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sidebar-stack {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
@media (max-width: 1180px) { @media (max-width: 720px) {
.metric-row {
grid-template-columns: 1fr;
}
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.page-intro, .page-intro,
.section-heading, .section-heading,
.intro-actions, .intro-actions,
.editor-actions { .editor-actions {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: stretch;
}
.intro-actions,
.editor-actions,
.primary-button,
.secondary-button {
width: 100%;
} }
.meta-grid,
.summary-grid { .summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.meta-grid, .sheet-table {
.sidebar-stack { min-width: 0;
grid-template-columns: 1fr; border-spacing: 0;
}
} }
@media (max-width: 880px) {
.sheet-table, .sheet-table,
.sheet-table thead, .sheet-table thead,
.sheet-table tbody, .sheet-table tbody,
@@ -920,61 +867,44 @@
width: 100%; width: 100%;
} }
.sheet-table {
min-width: 0;
border-spacing: 0;
}
.sheet-table thead { .sheet-table thead {
display: none; display: none;
} }
.sheet-table tbody { .sheet-table tbody {
display: grid; display: grid;
gap: 0.9rem; gap: 0.75rem;
} }
.sheet-table tbody tr { .sheet-table tbody tr {
padding: 0.35rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1rem; border-radius: 1rem;
background: var(--panel-soft); background: var(--panel-soft);
overflow: hidden;
} }
.sheet-table tbody td { .sheet-table tbody td {
padding: 0.78rem 0.8rem; border: none;
border-bottom: 1px solid var(--line);
border-left: none;
border-right: none;
border-radius: 0;
white-space: normal; white-space: normal;
border: none;
border-radius: 0;
background: transparent;
} }
.sheet-table tbody td:first-child,
.sheet-table tbody td:last-child { .sheet-table tbody td:last-child {
border: none; border-bottom: none;
border-radius: 0;
}
.sheet-table tbody td + td {
border-top: 1px solid var(--line);
} }
.sheet-table tbody td::before { .sheet-table tbody td::before {
content: attr(data-label); content: attr(data-label);
display: block; display: block;
margin-bottom: 0.35rem; margin-bottom: 0.24rem;
color: var(--muted); color: var(--muted);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
} }
.sheet-table input,
.sheet-table select,
.icon-delete {
width: 100%;
min-width: 0;
}
} }
</style> </style>
@@ -0,0 +1,129 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
export type AppNavSectionItem = {
label: string;
href?: string;
icon?: ComponentType;
active?: boolean;
onSelect?: () => void;
type?: 'button' | 'link';
};
let {
label = '',
ariaLabel = 'Navigation section',
items
}: {
label?: string;
ariaLabel?: string;
items: AppNavSectionItem[];
} = $props();
</script>
{#if label}
<p class="nav-section-label">{label}</p>
{/if}
<nav class="nav-list" aria-label={ariaLabel}>
{#each items as item}
{@const Icon = item.icon}
{#if item.href && item.type !== 'button'}
<a class:active={item.active} href={item.href}>
{#if Icon}
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
{/if}
<span>{item.label}</span>
</a>
{:else}
<button type="button" class="nav-button" class:active={item.active} onclick={item.onSelect}>
{#if Icon}
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
{/if}
<span>{item.label}</span>
</button>
{/if}
{/each}
</nav>
<style>
.nav-section-label {
margin: 0.85rem 0.55rem 0.3rem;
color: var(--nav-section-label-color, var(--muted));
font-size: var(--nav-section-label-size, 0.7rem);
font-weight: 700;
letter-spacing: var(--nav-section-label-spacing, 0.1em);
text-transform: uppercase;
}
.nav-list {
display: grid;
gap: 0.12rem;
}
.nav-list a,
.nav-button {
position: relative;
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.6rem 0.6rem;
border: none;
border-radius: 0.7rem;
background: transparent;
color: var(--nav-item-color, #3a4a41);
font-size: var(--nav-item-size, 0.93rem);
font-weight: var(--nav-item-weight, 500);
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-list a:hover,
.nav-button:hover {
background: var(--nav-item-hover-bg, var(--panel-soft));
color: var(--nav-item-hover-color, #304038);
}
.nav-list a.active,
.nav-button.active {
background: var(--nav-item-active-bg, var(--color-brand));
color: var(--nav-item-active-color, #fff);
font-weight: var(--nav-item-active-weight, 600);
}
.nav-list a.active::before,
.nav-button.active::before {
content: '';
position: absolute;
left: -0.85rem;
top: 0.45rem;
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--nav-item-active-marker, var(--color-brand));
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
color: var(--nav-icon-color, #6d7d74);
border-radius: 0.55rem;
transition: color 140ms ease;
}
.nav-list a:hover .nav-icon,
.nav-button:hover .nav-icon {
color: var(--nav-icon-hover-color, #304038);
}
.nav-list a.active .nav-icon,
.nav-button.active .nav-icon {
color: var(--nav-icon-active-color, #fff);
}
</style>
@@ -0,0 +1,200 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
import AppNavSection, { type AppNavSectionItem } from '$lib/components/navigation/AppNavSection.svelte';
export type AppSecondaryRailItem = {
id: string;
label: string;
icon?: ComponentType;
group?: string;
};
let {
sectionLabel,
identityTitle,
identitySubtitle,
identityAvatarText = '',
identityIcon,
groups,
activeId,
onSelect
}: {
sectionLabel: string;
identityTitle: string;
identitySubtitle: string;
identityAvatarText?: string;
identityIcon?: ComponentType;
groups: { label?: string; items: AppSecondaryRailItem[] }[];
activeId: string;
onSelect: (id: string) => void;
} = $props();
function toSectionItems(items: AppSecondaryRailItem[]): AppNavSectionItem[] {
return items.map((item) => ({
label: item.label,
icon: item.icon,
active: item.id === activeId,
onSelect: () => onSelect(item.id),
type: 'button'
}));
}
</script>
<nav class="secondary-rail" aria-label={`${sectionLabel} navigation`}>
<p class="rail-label">{sectionLabel}</p>
<div class="rail-identity">
<div class="rail-avatar" aria-hidden="true">
{#if identityIcon}
{@const IdentityIcon = identityIcon}
<IdentityIcon size={16} strokeWidth={1.75} />
{:else}
{identityAvatarText}
{/if}
</div>
<div class="rail-identity-text">
<p class="identity-name">{identityTitle}</p>
<p class="identity-role">{identitySubtitle}</p>
</div>
</div>
{#each groups as group}
<div class="rail-group">
<AppNavSection label={group.label ?? ''} ariaLabel={group.label ?? `${sectionLabel} section`} items={toSectionItems(group.items)} />
</div>
{/each}
</nav>
<style>
.secondary-rail {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
padding: 0;
background: color-mix(in srgb, var(--panel-soft) 72%, white);
border-right: 1px solid var(--line);
overflow-y: auto;
--nav-section-label-color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
--nav-section-label-size: 0.66rem;
--nav-section-label-spacing: 0.14em;
--nav-item-color: #66756d;
--nav-item-size: 0.88rem;
--nav-item-weight: 450;
--nav-item-hover-bg: color-mix(in srgb, var(--panel) 72%, transparent);
--nav-item-hover-color: #425148;
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 7%, transparent);
--nav-item-active-color: #22352d;
--nav-item-active-weight: 560;
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 28%, transparent);
--nav-icon-color: #8a9790;
--nav-icon-hover-color: #607067;
--nav-icon-active-color: var(--color-brand);
}
.rail-label {
margin: 0;
padding: 1rem 1rem 0.15rem;
color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.rail-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem 1.15rem;
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
}
.rail-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.15rem;
height: 2.15rem;
border-radius: 50%;
background: color-mix(in srgb, var(--panel) 80%, #edf2ee);
color: #6b786f;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
}
.rail-identity-text {
min-width: 0;
}
.identity-name {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
color: #526059;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.identity-role {
margin: 0;
font-size: 0.72rem;
color: #8a9790;
}
.rail-group {
display: grid;
gap: 0.08rem;
padding: 0.1rem 0.7rem 0;
}
.rail-group + .rail-group {
padding-top: 1rem;
}
.rail-group :global(.nav-section-label) {
margin-top: 0;
margin-left: 0.3rem;
margin-right: 0.3rem;
}
@media (max-width: 980px) {
.secondary-rail {
position: static;
min-height: auto;
height: auto;
overflow: visible;
border-right: none;
}
.rail-group {
gap: 0.3rem;
padding-top: 0;
}
.rail-group :global(.nav-section-label) {
width: 100%;
margin-left: 0;
margin-right: 0;
}
.rail-group :global(.nav-list) {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.rail-group :global(.nav-list a),
.rail-group :global(.nav-button) {
width: auto;
padding-right: 0.9rem;
}
}
</style>
@@ -0,0 +1,81 @@
<script lang="ts">
let {
rail,
children
}: {
rail: () => unknown;
children: () => unknown;
} = $props();
</script>
<div class="secondary-rail-layout">
<aside class="secondary-rail-layout-nav">
{@render rail()}
</aside>
<div class="secondary-rail-layout-panel">
<div class="secondary-rail-layout-content">
{@render children()}
</div>
</div>
</div>
<style>
.secondary-rail-layout {
margin: calc(var(--content-padding, 0rem) * -1);
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
align-items: stretch;
flex: 1;
height: calc(100% + (var(--content-padding, 0rem) * 2));
min-height: calc(100% + (var(--content-padding, 0rem) * 2));
overflow: clip;
}
.secondary-rail-layout-nav,
.secondary-rail-layout-panel {
min-width: 0;
min-height: 0;
}
.secondary-rail-layout-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--panel);
}
.secondary-rail-layout-content {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.secondary-rail-layout-content > :global(*) {
flex: 1 0 auto;
height: 100%;
min-height: 100%;
}
@media (max-width: 980px) {
.secondary-rail-layout {
grid-template-columns: 1fr;
flex: none;
height: auto;
min-height: auto;
max-height: none;
}
.secondary-rail-layout-nav {
border-bottom: 1px solid var(--line);
}
.secondary-rail-layout-content {
height: auto;
}
}
</style>
@@ -0,0 +1,251 @@
<script lang="ts">
import { LogOut, Settings } from 'lucide-svelte';
import AppNavSection from '$lib/components/navigation/AppNavSection.svelte';
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
let {
currentPath,
primaryItems,
workingDocumentItems,
footerItems,
appVersion,
releaseStage,
currentYear,
onOpenSettings,
onSignOut
}: {
currentPath: string;
primaryItems: NavItem[];
workingDocumentItems: NavItem[];
footerItems: FooterLink[];
appVersion: string;
releaseStage: string;
currentYear: number;
onOpenSettings: () => void;
onSignOut: () => void;
} = $props();
</script>
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
</a>
</div>
<div class="sidebar-body">
<AppNavSection
label="Modules"
ariaLabel="Client navigation"
items={primaryItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon,
active: matchesRoute(item.href, currentPath)
}))}
/>
{#if workingDocumentItems.length}
<AppNavSection
label="Working Docs"
ariaLabel="Working document pages"
items={workingDocumentItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon,
active: matchesRoute(item.href, currentPath)
}))}
/>
{/if}
{#if footerItems.length}
<AppNavSection
label="More"
ariaLabel="Workspace shortcuts"
items={footerItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon
}))}
/>
{/if}
<div class="sidebar-meta">
<AppNavSection
ariaLabel="Account actions"
items={[
{
label: 'Settings',
icon: Settings,
active: currentPath.startsWith('/settings'),
onSelect: onOpenSettings,
type: 'button'
},
{
label: 'Sign out',
icon: LogOut,
onSelect: onSignOut,
type: 'button'
}
]}
/>
<div class="sidebar-meta-foot">
<div class="sidebar-meta-top">
<span class="version-pill">
<span class="meta-label">Build</span>
<span>{appVersion}</span>
</span>
<span class="release-pill">{releaseStage}</span>
</div>
<div class="sidebar-meta-bottom">
<small>&copy; {currentYear} Hunter Premium Produce</small>
<div class="powered-by">
<span>Powered by</span>
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
<strong>Lean 101</strong>
</div>
</div>
</div>
</div>
</div>
</aside>
<style>
.sidebar {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
scrollbar-width: thin;
}
.sidebar-body {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 0.7rem;
}
.brand-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.68rem;
padding: 0.2rem 0.35rem 0.95rem;
border-bottom: 1px solid var(--line);
}
.brand {
display: block;
width: 100%;
}
.sidebar-logo {
width: min(100%, 15.5rem);
max-width: none;
height: auto;
display: block;
object-fit: contain;
}
.sidebar-meta {
margin-top: auto;
display: grid;
gap: 0.7rem;
padding-top: 1rem;
flex-shrink: 0;
}
.sidebar-meta-foot {
display: grid;
gap: 0.55rem;
padding: 0.8rem 0.55rem 0;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.76rem;
}
.sidebar-meta-top,
.sidebar-meta-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.sidebar-meta-foot small {
font-size: 0.72rem;
line-height: 1.35;
}
.powered-by {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--muted);
white-space: nowrap;
}
.powered-by span {
font-size: 0.72rem;
font-weight: 500;
}
.powered-by strong {
font-size: 0.76rem;
font-weight: 600;
color: #5e6c64;
}
.lean101-logo {
width: 1.45rem;
height: 1.45rem;
object-fit: contain;
opacity: 0.8;
flex-shrink: 0;
}
.version-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.24rem 0.56rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: #5e6c64;
font-size: 0.72rem;
font-weight: 600;
}
.meta-label {
color: var(--muted);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.release-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem 0.52rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-brand) 8%, white);
color: var(--color-brand);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
</style>
@@ -0,0 +1,395 @@
<script lang="ts">
import { Settings } from 'lucide-svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import type { AppSession } from '$lib/session';
import type { Crumb } from '$lib/navigation/client-navigation';
let {
breadcrumbs,
title,
sessionHydrated,
session,
userInitials,
userMenuOpen,
onOpenPalette,
onToggleUserMenu,
onOpenSettings,
onSignOut
}: {
breadcrumbs: Crumb[];
title: string;
sessionHydrated: boolean;
session: AppSession | null;
userInitials: string;
userMenuOpen: boolean;
onOpenPalette: () => void;
onToggleUserMenu: () => void;
onOpenSettings: () => void;
onSignOut: () => void;
} = $props();
</script>
<header class="topbar">
<div class="topbar-start">
<div class="topbar-copy">
<nav class="breadcrumbs" aria-label="Breadcrumb">
{#each breadcrumbs as crumb, index}
{#if index > 0}<span class="breadcrumb-sep" aria-hidden="true">/</span>{/if}
{#if crumb.href && index < breadcrumbs.length - 1}
<a href={crumb.href}>{crumb.label}</a>
{:else}
<span aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>{crumb.label}</span>
{/if}
{/each}
</nav>
<h1>{title}</h1>
</div>
</div>
<div class="topbar-middle">
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
</div>
<div class="topbar-actions">
<div class="menu-wrap user-menu-wrap">
<button aria-expanded={userMenuOpen} class="user-trigger" type="button" onclick={onToggleUserMenu}>
<span class="user-avatar-wrap">
<span class="user-avatar">{session ? userInitials : '?'}</span>
<span class={`user-status-dot ${session ? 'live' : 'idle'}`}></span>
</span>
<span class="user-trigger-copy">
<span class="workspace-label">{sessionHydrated ? (session ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
<strong>{sessionHydrated ? (session ? session.name || 'Client account' : '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">
<span class="user-menu-avatar">{session ? userInitials : '?'}</span>
<div class="user-menu-summary-text">
<strong>
{sessionHydrated
? session
? session.name || 'Client account'
: 'Client session inactive'
: 'Checking saved client session'}
</strong>
<span>
{sessionHydrated
? session
? session.email
: 'Return to the dashboard page to sign in.'
: 'Waiting for the browser session check to complete.'}
</span>
</div>
</div>
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
<Settings size={15} strokeWidth={1.75} />
Settings
</button>
{#if session}
<button type="button" onclick={onSignOut}>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>
</header>
<style>
.topbar {
display: grid;
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr;
align-items: center;
gap: 0.75rem;
padding: 0.72rem 1.2rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.topbar-start {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 0.82rem;
}
.topbar-copy h1 {
margin: 0.12rem 0 0;
font-size: 1.34rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.32rem;
color: var(--muted);
font-size: 0.74rem;
font-weight: 500;
}
.breadcrumbs a {
color: var(--muted);
transition: color 140ms ease;
}
.breadcrumbs a:hover {
color: var(--green-deep);
}
.breadcrumbs span[aria-current='page'] {
color: var(--text);
font-weight: 600;
}
.breadcrumb-sep {
color: #b9c5be;
font-size: 0.78rem;
}
.topbar-middle {
min-width: 0;
display: flex;
justify-content: center;
}
:global(.topbar-search) {
width: 100%;
min-height: 2.75rem;
background: color-mix(in srgb, var(--panel-soft) 68%, white);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.68rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.menu-wrap {
position: relative;
}
.user-trigger {
min-width: 14rem;
display: inline-flex;
align-items: center;
gap: 0.72rem;
padding: 0.56rem 0.76rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: var(--panel-soft);
color: #304038;
text-align: left;
cursor: pointer;
}
.user-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.user-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
user-select: none;
}
.user-status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
border: 1.5px solid var(--panel-soft);
background: #b4c0ba;
}
.user-status-dot.live {
background: #4ade80;
}
.user-status-dot.idle {
background: #c08b3d;
}
.user-menu-avatar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.02em;
user-select: none;
}
.user-menu-summary {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.72rem 0.78rem;
border-radius: 0.82rem;
background: var(--panel-soft);
}
.user-menu-summary-text {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.user-menu-summary-text strong {
font-size: 0.9rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-menu-summary-text span {
color: var(--muted);
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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;
}
.menu-settings-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chevron {
width: 0.54rem;
height: 0.54rem;
border-right: 2px solid #7a8c82;
border-bottom: 2px solid #7a8c82;
transform: rotate(45deg);
transition: transform 140ms ease;
}
.chevron.open {
transform: rotate(-135deg);
}
.menu-panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 20;
min-width: 13rem;
display: grid;
gap: 0.18rem;
padding: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(10px);
}
.menu-panel a,
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: #304038;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.menu-panel a:hover,
.menu-panel button:hover {
background: var(--panel-soft);
}
@media (max-width: 1180px) {
.topbar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'start actions'
'middle middle';
}
.topbar-start {
grid-area: start;
}
.topbar-middle {
grid-area: middle;
}
.topbar-actions {
grid-area: actions;
}
}
@media (max-width: 700px) {
.topbar {
padding: 0.72rem 1rem;
}
.user-trigger {
min-width: auto;
width: 100%;
}
}
</style>
@@ -0,0 +1,85 @@
<script lang="ts">
let {
label = 'Search the workspace',
placeholder = 'Search products, mixes, sessions, and pages...',
className = '',
onClick
}: {
label?: string;
placeholder?: string;
className?: string;
onClick: () => void;
} = $props();
</script>
<button class={`search-box ${className}`.trim()} type="button" aria-label={label} onclick={onClick}>
<span class="search-icon"></span>
<span class="search-placeholder">{placeholder}</span>
<kbd>/</kbd>
</button>
<style>
.search-box {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.64rem;
width: 100%;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.82rem;
background: var(--panel-soft);
text-align: left;
cursor: pointer;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.search-box:hover {
border-color: color-mix(in srgb, var(--color-brand) 22%, var(--line));
background: #fff;
}
.search-box:focus-visible {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 16%, transparent);
}
.search-placeholder {
color: #93a098;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-icon {
position: relative;
display: inline-block;
width: 0.82rem;
height: 0.82rem;
border: 2px solid #98a59d;
border-radius: 999px;
}
.search-icon::after {
content: '';
position: absolute;
right: -0.28rem;
bottom: -0.18rem;
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: #98a59d;
transform: rotate(45deg);
}
kbd {
padding: 0.1rem 0.42rem;
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: #fff;
font-size: 0.76rem;
}
</style>
@@ -0,0 +1,204 @@
import {
Boxes,
Calculator,
ClipboardList,
DollarSign,
FlaskConical,
LayoutDashboard,
ShieldCheck,
TrendingUp,
Wheat,
Workflow
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
import { featureFlags } from '$lib/features';
export type SearchItem = {
href: string;
label: string;
description: string;
keywords: string;
};
export type NavItem = {
href: string;
label: string;
shortLabel: string;
icon: ComponentType;
moduleKey?: string;
};
export type FooterLink = {
href: string;
label: string;
shortLabel: string;
icon: ComponentType;
};
export type Crumb = {
label: string;
href?: string;
};
export const dashboardItem: NavItem = {
href: '/',
label: 'Dashboard',
shortLabel: 'DB',
icon: LayoutDashboard,
moduleKey: 'dashboard'
};
export const mixCalculatorItem: NavItem = {
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
label: 'Mix Calculator',
shortLabel: 'MC',
icon: Calculator,
moduleKey: 'mix_calculator'
};
export const reportingItem: NavItem = {
href: '/reporting',
label: 'Reporting',
shortLabel: 'RP',
icon: TrendingUp,
moduleKey: 'products'
};
export const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', icon: Workflow, moduleKey: 'scenarios' }
];
export const accessControlItem: NavItem = {
href: '/client-access',
label: 'Client Access',
shortLabel: 'AC',
icon: ShieldCheck,
moduleKey: 'client_access'
};
export const clientNavigationItems: NavItem[] = [
dashboardItem,
mixCalculatorItem,
...workingDocumentItems,
accessControlItem
];
export const footerLinks: FooterLink[] = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
];
export const baseSearchItems: SearchItem[] = [
{
href: '/',
label: 'Open Dashboard',
description: 'Jump to the Hunter Premium Produce workspace summary.',
keywords: 'hunter premium produce overview dashboard workspace home'
},
{
href: '/raw-materials',
label: 'Open Raw Materials',
description: 'Review live input costs that feed the pricing model.',
keywords: 'raw materials pricing inputs costs supplier'
},
{
href: '/mixes',
label: 'Open Mix Master',
description: 'Browse saved mixes and their costing outputs.',
keywords: 'mix master mixes recipes spreadsheet'
},
{
href: '/mixes/new',
label: 'Create New Mix',
description: 'Start a new costing worksheet for Hunter Premium Produce.',
keywords: 'new mix create worksheet hunter premium produce formula'
},
...(featureFlags.mixCalculatorSessionHistory
? [
{
href: '/mix-calculator',
label: 'Open Mix Calculator',
description: 'Review saved production sessions and batch calculations.',
keywords: 'mix calculator production sessions batch bags client product'
}
]
: []),
{
href: '/mix-calculator/new',
label: 'Create Mix Calculation',
description: 'Run a new client-specific mix calculation session.',
keywords: 'new mix calculator session client batch size product bags print'
},
{
href: '/products',
label: 'Open Products',
description: 'Review delivered product pricing and margins.',
keywords: 'products pricing margins delivered outputs'
},
{
href: '/reporting',
label: 'Open Reporting',
description: 'View raw material costs, mix summaries, product pricing, and data quality reports.',
keywords: 'reporting reports raw materials mix cost product pricing data quality price review'
},
{
href: '/settings',
label: 'Open Workspace Settings',
description: 'Review account details and workspace preferences.',
keywords: 'settings account preferences profile workspace'
},
{
href: '/scenarios',
label: 'Open Scenarios',
description: 'Inspect planning scenarios and overrides.',
keywords: 'scenarios sandbox overrides compare planning'
}
];
export function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href);
}
export function pageTitle(pathname: string) {
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
}
export function clientBreadcrumbs(pathname: string): Crumb[] {
const root: Crumb = { label: 'Workspace', href: '/' };
if (pathname === '/') {
return [root, { label: 'Dashboard' }];
}
if (pathname.startsWith('/mix-calculator')) {
const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }];
if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' });
else if (pathname.endsWith('/print')) trail.push({ label: 'Print' });
else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' });
return trail;
}
if (pathname.startsWith('/mixes')) {
const trail: Crumb[] = [root, { label: 'Mix Master', href: '/mixes' }];
if (pathname === '/mixes/new') trail.push({ label: 'New Mix' });
else if (pathname !== '/mixes') trail.push({ label: 'Detail' });
return trail;
}
const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials',
'/products': 'Products',
'/scenarios': 'Scenarios',
'/client-access': 'Client Access',
'/reporting': 'Reporting',
'/settings': 'Settings'
};
const section = sectionMap[pathname];
if (section) return [root, { label: section }];
return [root, { label: pageTitle(pathname) }];
}
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte'; import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
import { featureFlags } from '$lib/features'; import { featureFlags } from '$lib/features';
let { data } = $props(); let { data } = $props();
</script> </script>
{#if data.session} {#if data.session}
<MixCalculatorWorkspace initialSession={data.session} options={data.options} /> <MixCalculatorEditor initialSession={data.session} options={data.options} />
{:else} {:else}
<section class="locked-card"> <section class="locked-card">
<p class="eyebrow">Mix Calculator</p> <p class="eyebrow">Mix Calculator</p>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte'; import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
let { data } = $props(); let { data } = $props();
</script> </script>
<MixCalculatorWorkspace options={data.options} /> <MixCalculatorEditor options={data.options} />
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import MixWorkspace from '$lib/components/MixWorkspace.svelte'; import MixEditor from '$lib/components/mixes/MixEditor.svelte';
let { data } = $props(); let { data } = $props();
</script> </script>
<MixWorkspace rawMaterials={data.rawMaterials} initialMix={data.mix} /> <MixEditor rawMaterials={data.rawMaterials} initialMix={data.mix} />
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import MixWorkspace from '$lib/components/MixWorkspace.svelte'; import MixEditor from '$lib/components/mixes/MixEditor.svelte';
let { data } = $props(); let { data } = $props();
</script> </script>
<MixWorkspace rawMaterials={data.rawMaterials} /> <MixEditor rawMaterials={data.rawMaterials} />
+25 -222
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session'; import { clientSession } from '$lib/session';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { BarChart3, CirclePlus, Wheat } from 'lucide-svelte'; import { BarChart3, CirclePlus, Wheat } from 'lucide-svelte';
@@ -47,7 +49,10 @@
} }
]; ];
const railGroups = [...new Set(railItems.map((item) => item.group))]; const railGroups = [...new Set(railItems.map((item) => item.group))].map((group) => ({
label: group,
items: railItems.filter((item) => item.group === group)
}));
let activeView = $state<RawMaterialsView>('overview'); let activeView = $state<RawMaterialsView>('overview');
const pageSize = 20; const pageSize = 20;
let overviewMixesPage = $state(1); let overviewMixesPage = $state(1);
@@ -229,38 +234,18 @@
<p class="feedback error">{errorMessage}</p> <p class="feedback error">{errorMessage}</p>
{/if} {/if}
<div class="workspace-layout"> <AppSecondaryRailLayout>
<nav class="workspace-nav" aria-label="Raw materials navigation"> {#snippet rail()}
<p class="nav-section-label">Raw Materials</p> <AppSecondaryRail
sectionLabel="Raw Materials"
<div class="nav-identity"> identityTitle={`${activeMaterials.length} active inputs`}
<div class="nav-avatar" aria-hidden="true"> identitySubtitle={`${data.rawMaterials.length} tracked materials`}
<Wheat size={16} strokeWidth={1.75} /> identityIcon={Wheat}
</div> groups={railGroups}
<div class="nav-identity-text"> activeId={activeView}
<p class="identity-name">{activeMaterials.length} active inputs</p> onSelect={(id) => (activeView = id as RawMaterialsView)}
<p class="identity-role">{data.rawMaterials.length} tracked materials</p> />
</div> {/snippet}
</div>
{#each railGroups as group}
<div class="nav-group">
<p class="nav-group-label">{group}</p>
{#each railItems.filter((item) => item.group === group) as item}
{@const Icon = item.icon}
<button
type="button"
class="nav-item"
class:active={activeView === item.id}
onclick={() => (activeView = item.id)}
>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</button>
{/each}
</div>
{/each}
</nav>
<div class="workspace-panel"> <div class="workspace-panel">
{#if activeRailItem} {#if activeRailItem}
@@ -620,7 +605,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div> </AppSecondaryRailLayout>
{/if} {/if}
<style> <style>
@@ -651,7 +636,7 @@
.locked-card, .locked-card,
.feedback, .feedback,
.workspace-layout { :global(.secondary-rail-layout) {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
@@ -688,82 +673,6 @@
font-weight: 600; font-weight: 600;
} }
.workspace-layout {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
align-items: stretch;
min-height: calc(100vh - 8.5rem);
max-height: calc(100vh - 8.5rem);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.15rem;
box-shadow: var(--shadow);
overflow: hidden;
}
.workspace-nav {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.nav-section-label {
margin: 0 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.25rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.nav-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
}
.nav-identity-text {
min-width: 0;
}
.identity-name {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.identity-role {
margin: 0;
font-size: 0.74rem;
color: var(--muted);
}
.feedback { .feedback {
padding: 0.95rem 1rem; padding: 0.95rem 1rem;
font-weight: 600; font-weight: 600;
@@ -784,96 +693,22 @@
.metric-row, .metric-row,
.top-grid, .top-grid,
.material-grid, .material-grid,
.impact-grid, .impact-grid {
.nav-group {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
.nav-group {
gap: 0.12rem;
padding-top: 0.15rem;
}
.nav-group-label {
margin: 0.15rem 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.6rem 0.6rem;
border: none;
border-radius: 0.7rem;
background: transparent;
color: #3a4a41;
font-size: 0.93rem;
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
color: #6d7d74;
border-radius: 0.55rem;
transition: color 140ms ease;
}
.nav-item:hover {
background: var(--panel-soft);
color: #304038;
}
.nav-item.active {
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
.nav-item:hover .nav-icon {
color: #304038;
}
.nav-item.active .nav-icon {
color: #fff;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -0.85rem;
top: 0.45rem;
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--color-brand);
}
.workspace-panel { .workspace-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
background: var(--panel); background: var(--panel);
height: 100%;
overflow: hidden;
} }
.panel-header { .panel-header {
position: sticky;
top: 0;
z-index: 2;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 1rem;
@@ -913,10 +748,7 @@
} }
.panel-body { .panel-body {
flex: 1;
min-height: 0;
padding: 1.5rem; padding: 1.5rem;
overflow-y: auto;
} }
.metric-row { .metric-row {
@@ -1194,7 +1026,7 @@
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.workspace-layout, :global(.secondary-rail-layout),
.metric-row, .metric-row,
.top-grid, .top-grid,
.material-grid, .material-grid,
@@ -1202,19 +1034,6 @@
.stats-grid { .stats-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.workspace-nav {
position: static;
min-height: auto;
height: auto;
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--line);
}
.workspace-layout {
min-height: auto;
max-height: none;
}
} }
@media (max-width: 820px) { @media (max-width: 820px) {
@@ -1239,21 +1058,5 @@
align-items: flex-start; align-items: flex-start;
} }
.nav-group {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding-top: 0;
}
.nav-group-label {
width: 100%;
margin-left: 0;
margin-right: 0;
}
.nav-item {
width: auto;
}
} }
</style> </style>
+159 -291
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { import {
BarChart3, BarChart3,
TrendingUp, TrendingUp,
@@ -12,6 +14,8 @@
import type { ComponentType } from 'svelte'; import type { ComponentType } from 'svelte';
type ReportId = type ReportId =
| 'sales-target-report'
| 'finished-product-kanban'
| 'summary' | 'summary'
| 'raw-material-costs' | 'raw-material-costs'
| 'mix-cost-summary' | 'mix-cost-summary'
@@ -28,6 +32,20 @@
}; };
const reports: ReportItem[] = [ const reports: ReportItem[] = [
{
id: 'sales-target-report',
label: 'Sales Target Report',
description: 'Embedded Power BI sales target view for current sales tracking and review.',
icon: FileText,
group: 'Power BI',
},
{
id: 'finished-product-kanban',
label: 'Finished Product - Kanban',
description: 'Embedded Power BI board for finished product review and kanban-style planning.',
icon: FileText,
group: 'Power BI',
},
{ {
id: 'summary', id: 'summary',
label: 'Overview', label: 'Overview',
@@ -72,61 +90,40 @@
}, },
]; ];
const groups = [...new Set(reports.map((r) => r.group))]; const SALES_TARGET_REPORT_URL =
'https://app.powerbi.com/view?r=eyJrIjoiZjc1NjljNmEtMmJkMi00ZDlmLThjN2MtNjgyMzcxZDUwMzIyIiwidCI6IjEwNjk4Y2EyLTVmMmUtNGUwYS04ZTQ2LWE5ZGI0Nzk3ZjQ3MCJ9';
let activeId = $state<ReportId>('summary'); const FINISHED_PRODUCT_KANBAN_URL =
'https://app.powerbi.com/view?r=eyJrIjoiOTBjYTQ2MjMtZjMwNi00MjAzLTgxNDYtMmEzM2QwNjhlNmFlIiwidCI6IjEwNjk4Y2EyLTVmMmUtNGUwYS04ZTQ2LWE5ZGI0Nzk3ZjQ3MCJ9';
const orderedGroups = ['Power BI', 'Overview', 'Costing', 'Quality'];
const railGroups = orderedGroups
.filter((group) => reports.some((report) => report.group === group))
.map((group) => ({
label: group,
items: reports.filter((report) => report.group === group)
}));
let activeId = $state<ReportId>('sales-target-report');
const activeReport = $derived(reports.find((r) => r.id === activeId) ?? reports[0]); const activeReport = $derived(reports.find((r) => r.id === activeId) ?? reports[0]);
</script> </script>
<div class="reporting-layout"> <AppSecondaryRailLayout>
<nav class="report-nav" aria-label="Report navigation"> {#snippet rail()}
<p class="nav-section-label">Reporting</p> <AppSecondaryRail
sectionLabel="Reporting"
<div class="nav-identity"> identityTitle="Workspace reports"
<div class="nav-avatar" aria-hidden="true"> identitySubtitle="Costing and quality views"
<TrendingUp size={16} strokeWidth={1.75} /> identityIcon={TrendingUp}
</div> groups={railGroups}
<div class="nav-identity-text"> activeId={activeId}
<p class="identity-name">Workspace reports</p> onSelect={(id) => (activeId = id as ReportId)}
<p class="identity-role">Costing and quality views</p> />
</div> {/snippet}
</div>
{#each groups as group}
<div class="nav-group">
<p class="nav-group-label">{group}</p>
{#each reports.filter((r) => r.group === group) as report}
{@const Icon = report.icon}
<button
type="button"
class="nav-item"
class:active={activeId === report.id}
onclick={() => (activeId = report.id)}
>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{report.label}</span>
</button>
{/each}
</div>
{/each}
</nav>
<div class="report-panel"> <div class="report-panel">
{#if activeReport}
{@const PanelIcon = activeReport.icon}
<header class="panel-header">
<div class="panel-header-icon" aria-hidden="true">
<PanelIcon size={16} strokeWidth={1.75} />
</div>
<div>
<p class="panel-eyebrow">{activeReport.group}</p>
<h2>{activeReport.label}</h2>
<p class="panel-description">{activeReport.description}</p>
</div>
</header>
{/if}
<div class="panel-body"> <div class="panel-body">
{#if activeId === 'summary'} {#if activeId === 'summary'}
<div class="report-placeholder"> <div class="report-placeholder">
@@ -244,227 +241,127 @@
{/each} {/each}
</div> </div>
</div> </div>
{:else if activeId === 'sales-target-report'}
<section class="powerbi-embed">
<header class="embed-header">
<div>
<h2>Sales Target Report</h2>
<p>Live embedded Power BI view for sales target tracking and review.</p>
</div>
<a class="powerbi-link" href={SALES_TARGET_REPORT_URL} target="_blank" rel="noreferrer">
Open in Power BI
</a>
</header>
<div class="powerbi-frame-shell">
<iframe
title="Sales Target Report"
src={SALES_TARGET_REPORT_URL}
class="powerbi-frame"
allowfullscreen
></iframe>
</div>
</section>
{:else if activeId === 'finished-product-kanban'}
<section class="powerbi-embed">
<header class="embed-header">
<div>
<h2>Finished Product - Kanban</h2>
<p>Live embedded Power BI view for finished product review and kanban-style planning.</p>
</div>
<a class="powerbi-link" href={FINISHED_PRODUCT_KANBAN_URL} target="_blank" rel="noreferrer">
Open in Power BI
</a>
</header>
<div class="powerbi-frame-shell">
<iframe
title="Finished Product - Kanban"
src={FINISHED_PRODUCT_KANBAN_URL}
class="powerbi-frame"
allowfullscreen
></iframe>
</div>
</section>
{/if} {/if}
</div> </div>
</div> </div>
</div> </AppSecondaryRailLayout>
<style> <style>
.reporting-layout {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
align-items: stretch;
min-height: calc(100vh - 8.5rem);
max-height: calc(100vh - 8.5rem);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.15rem;
box-shadow: var(--shadow);
overflow: hidden;
}
.report-nav {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.nav-section-label {
margin: 0 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.25rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.nav-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
}
.nav-identity-text {
min-width: 0;
}
.identity-name {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.identity-role {
margin: 0;
font-size: 0.74rem;
color: var(--muted);
}
.nav-group {
display: grid;
gap: 0.12rem;
padding-top: 0.15rem;
}
.nav-group + .nav-group {
padding-top: 0.7rem;
}
.nav-group-label {
margin: 0.15rem 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.6rem 0.6rem;
border: none;
border-radius: 0.7rem;
background: transparent;
color: #3a4a41;
font-size: 0.93rem;
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
color: #6d7d74;
border-radius: 0.55rem;
transition: color 140ms ease;
}
.nav-item:hover {
background: var(--panel-soft);
color: #304038;
}
.nav-item.active {
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
.nav-item:hover .nav-icon {
color: #304038;
}
.nav-item.active .nav-icon {
color: #fff;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -0.85rem;
top: 0.45rem;
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--color-brand);
}
.report-panel { .report-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
background: var(--panel); background: var(--panel);
height: 100%;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
}
.panel-header-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.4rem;
height: 2.4rem;
border-radius: 0.72rem;
background: var(--color-brand-tint);
color: var(--color-brand);
border: 1px solid color-mix(in srgb, var(--color-brand) 15%, transparent);
margin-top: 0.15rem;
}
.panel-eyebrow {
margin: 0;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.panel-header h2 {
margin: 0.2rem 0 0.3rem;
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
}
.panel-description {
margin: 0;
font-size: 0.84rem;
color: var(--muted);
line-height: 1.5;
} }
.panel-body { .panel-body {
flex: 1; padding: 1.25rem 1.35rem 1.35rem;
min-height: 0; }
padding: 1.5rem;
overflow-y: auto; .powerbi-embed {
display: grid;
gap: 0.85rem;
min-height: 42rem;
}
.embed-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
padding: 0 0 0.35rem;
background: color-mix(in srgb, var(--panel) 90%, transparent);
backdrop-filter: blur(8px);
}
.embed-header h2 {
margin: 0;
color: var(--text);
font-size: 1.08rem;
font-weight: 700;
}
.embed-header p {
margin: 0.18rem 0 0;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.45;
}
.powerbi-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.56rem 0.2rem;
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-brand) 22%, transparent);
border-radius: 0;
background: transparent;
color: #405148;
font-size: 0.84rem;
font-weight: 600;
white-space: nowrap;
}
.powerbi-frame-shell {
min-height: 36rem;
border-top: 1px solid color-mix(in srgb, var(--line) 88%, transparent);
overflow: hidden;
background: #f6f8f6;
}
.powerbi-frame {
width: 100%;
height: min(78vh, 980px);
border: 0;
background: #fff;
} }
/* ── Report placeholders ───────────────────────────────────────── */ /* ── Report placeholders ───────────────────────────────────────── */
@@ -598,43 +495,14 @@
/* ── Responsive ────────────────────────────────────────────────── */ /* ── Responsive ────────────────────────────────────────────────── */
@media (max-width: 860px) {
.reporting-layout {
grid-template-columns: 1fr;
min-height: auto;
max-height: none;
}
.report-nav {
position: static;
min-height: auto;
height: auto;
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--line);
}
.nav-group {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding-top: 0;
}
.nav-group-label {
width: 100%;
margin-left: 0;
margin-right: 0;
}
.nav-item {
width: auto;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.panel-header { .embed-header {
flex-direction: column; flex-direction: column;
align-items: start;
}
.powerbi-link {
width: 100%;
} }
.preview-header-row, .preview-header-row,
+17 -215
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api';
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session'; import { clientSession } from '$lib/session';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { CircleUserRound, LockKeyhole } from 'lucide-svelte'; import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
@@ -81,37 +83,22 @@
{ id: 'profile', label: 'Profile', icon: CircleUserRound }, { id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole }, { id: 'security', label: 'Security', icon: LockKeyhole },
]; ];
const railGroups = [{ items: navItems }];
</script> </script>
<div class="settings-layout"> <AppSecondaryRailLayout>
<nav class="settings-nav" aria-label="Settings sections"> {#snippet rail()}
<p class="nav-section-label">Settings</p> <AppSecondaryRail
sectionLabel="Settings"
<div class="nav-identity"> identityAvatarText={initials}
<div class="avatar" aria-hidden="true">{initials}</div> identityTitle={$clientSession?.name ?? 'Unknown'}
<div class="identity-text"> identitySubtitle={$clientSession?.role_name ?? $clientSession?.role ?? 'User'}
<p class="identity-name">{$clientSession?.name ?? 'Unknown'}</p> groups={railGroups}
<p class="identity-role">{$clientSession?.role_name ?? $clientSession?.role ?? 'User'}</p> activeId={activeSection}
</div> onSelect={(id) => (activeSection = id as Section)}
</div> />
{/snippet}
<ul>
{#each navItems as item}
{@const Icon = item.icon}
<li>
<button
type="button"
class="nav-item"
class:active={activeSection === item.id}
onclick={() => (activeSection = item.id)}
>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</button>
</li>
{/each}
</ul>
</nav>
<div class="settings-panel"> <div class="settings-panel">
{#if activeSection === 'profile'} {#if activeSection === 'profile'}
@@ -180,175 +167,19 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </AppSecondaryRailLayout>
<style> <style>
.settings-layout {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
align-items: stretch;
min-height: calc(100vh - 8.5rem);
max-height: calc(100vh - 8.5rem);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.15rem;
box-shadow: var(--shadow);
overflow: hidden;
}
.settings-nav {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.nav-section-label {
margin: 0 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.25rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.avatar {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
font-size: 0.9rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 0.02em;
}
.identity-text {
min-width: 0;
}
.identity-name {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.identity-role {
margin: 0;
font-size: 0.74rem;
color: var(--muted);
text-transform: capitalize;
}
.settings-nav ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.12rem;
}
.settings-nav li {
margin: 0;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.6rem 0.6rem;
border: none;
border-radius: 0.7rem;
background: transparent;
color: #3a4a41;
font-size: 0.93rem;
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
color: #6d7d74;
border-radius: 0.55rem;
transition: color 140ms ease;
}
.nav-item:hover {
background: var(--panel-soft);
color: #304038;
}
.nav-item.active {
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
.nav-item:hover .nav-icon {
color: #304038;
}
.nav-item.active .nav-icon {
color: #fff;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -0.85rem;
top: 0.45rem;
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--color-brand);
}
.settings-panel { .settings-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
background: var(--panel); background: var(--panel);
height: 100%;
overflow: hidden;
} }
.panel-section { .panel-section {
display: flex; display: flex;
flex: 1;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.panel-header { .panel-header {
@@ -372,10 +203,7 @@
.panel-form { .panel-form {
display: grid; display: grid;
flex: 1;
gap: 1rem; gap: 1rem;
min-height: 0;
overflow-y: auto;
width: 100%; width: 100%;
padding: 1.5rem 1.75rem; padding: 1.5rem 1.75rem;
max-width: 42rem; max-width: 42rem;
@@ -464,32 +292,6 @@
/* ── Responsive ─────────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) { @media (max-width: 720px) {
.settings-layout {
grid-template-columns: 1fr;
min-height: auto;
max-height: none;
}
.settings-nav {
position: static;
min-height: auto;
height: auto;
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--line);
}
.settings-nav ul {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.nav-item {
width: auto;
padding-right: 0.9rem;
}
.field-row { .field-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }