Deployment Script, Postgres migration, UX improvements
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
# 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`.
|
||||
|
||||
@@ -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 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 (
|
||||
build_mix_calculator_options,
|
||||
can_view_all_mix_calculator_sessions,
|
||||
calculate_mix_calculator_preview,
|
||||
serialize_mix_calculator_session,
|
||||
create_mix_calculator_session,
|
||||
get_mix_calculator_session,
|
||||
update_mix_calculator_session,
|
||||
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"])
|
||||
|
||||
@@ -77,6 +79,28 @@ def read_mix_calculator_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)
|
||||
def patch_mix_calculator_session(
|
||||
session_id: int,
|
||||
|
||||
+52
-6
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from collections import Counter
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
@@ -22,10 +23,48 @@ from app.services.client_access_service import MODULE_CATALOG, default_access_le
|
||||
TENANT_ID = "hunter-premium-produce"
|
||||
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -129,9 +168,12 @@ def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking
|
||||
|
||||
|
||||
def _load_workbook():
|
||||
if not WORKBOOK_PATH.exists():
|
||||
raise FileNotFoundError(f"Workbook not found at {WORKBOOK_PATH}")
|
||||
return load_workbook(WORKBOOK_PATH, data_only=True)
|
||||
workbook_path = _resolve_workbook_path()
|
||||
if not workbook_path.exists():
|
||||
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]:
|
||||
@@ -684,10 +726,14 @@ def seed_costing_workspace(db):
|
||||
def seed_if_empty():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with SessionLocal() as db:
|
||||
if WORKBOOK_PATH.exists():
|
||||
workbook_path = _resolve_workbook_path()
|
||||
if workbook_path.exists():
|
||||
seed_costing_workspace(db)
|
||||
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_access(db)
|
||||
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)
|
||||
@@ -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} · {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
|
||||
Name: data-entry-app-backend
|
||||
Version: 0.1.2
|
||||
Version: 0.1.5
|
||||
Summary: Costing platform MVP backend
|
||||
Requires-Python: >=3.11
|
||||
Requires-Dist: fastapi<1.0,>=0.115
|
||||
Requires-Dist: openpyxl<4.0,>=3.1
|
||||
Requires-Dist: uvicorn[standard]<1.0,>=0.30
|
||||
Requires-Dist: sqlalchemy<3.0,>=2.0
|
||||
Requires-Dist: pydantic<3.0,>=2.8
|
||||
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/main.py
|
||||
./app/seed.py
|
||||
./app/seed_access.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/powerbi.py
|
||||
./app/api/products.py
|
||||
./app/api/raw_materials.py
|
||||
./app/api/scenarios.py
|
||||
./app/core/__init__.py
|
||||
./app/core/access.py
|
||||
./app/core/config.py
|
||||
./app/core/security.py
|
||||
./app/db/__init__.py
|
||||
./app/db/migrations.py
|
||||
./app/db/session.py
|
||||
./app/models/__init__.py
|
||||
./app/models/access.py
|
||||
./app/models/assumption.py
|
||||
./app/models/client_access.py
|
||||
./app/models/mix.py
|
||||
./app/models/mix_calculator.py
|
||||
./app/models/product.py
|
||||
./app/models/raw_material.py
|
||||
./app/models/scenario.py
|
||||
./app/schemas/__init__.py
|
||||
./app/schemas/client_access.py
|
||||
./app/schemas/mix.py
|
||||
./app/schemas/mix_calculator.py
|
||||
./app/schemas/product.py
|
||||
./app/schemas/raw_material.py
|
||||
./app/schemas/scenario.py
|
||||
./app/services/__init__.py
|
||||
./app/services/client_access_service.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/__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/SOURCES.txt
|
||||
data_entry_app_backend.egg-info/dependency_links.txt
|
||||
data_entry_app_backend.egg-info/requires.txt
|
||||
data_entry_app_backend.egg-info/top_level.txt
|
||||
tests/test_access.py
|
||||
tests/test_costing_engine.py
|
||||
@@ -1,5 +1,8 @@
|
||||
fastapi<1.0,>=0.115
|
||||
openpyxl<4.0,>=3.1
|
||||
uvicorn[standard]<1.0,>=0.30
|
||||
sqlalchemy<3.0,>=2.0
|
||||
pydantic<3.0,>=2.8
|
||||
pytest<9.0,>=8.0
|
||||
psycopg[binary]<4.0,>=3.2
|
||||
reportlab<5.0,>=4.2
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"pydantic>=2.8,<3.0",
|
||||
"pytest>=8.0,<9.0",
|
||||
"psycopg[binary]>=3.2,<4.0",
|
||||
"reportlab>=4.2,<5.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -344,6 +344,41 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
||||
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():
|
||||
with TestClient(app) as client:
|
||||
admin_login_response = client.post(
|
||||
|
||||
+98
-135
@@ -1,68 +1,45 @@
|
||||
<#
|
||||
.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
|
||||
Runs `docker compose` against `docker-compose.production.yml` on the remote
|
||||
host. The same script handles first-time bootstrap and subsequent updates:
|
||||
Tars the local source tree, uploads it to the droplet, and runs
|
||||
docker compose up --build. No git required on the server.
|
||||
|
||||
* On bootstrap (-Bootstrap): creates the remote directory, clones the
|
||||
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).
|
||||
The same script handles first-time setup and subsequent updates.
|
||||
|
||||
.PARAMETER RemoteHost
|
||||
Hostname or IP of the Digital Ocean droplet. Required.
|
||||
|
||||
.PARAMETER RemoteUser
|
||||
SSH user on the droplet. Defaults to `root`.
|
||||
SSH user. Defaults to 'root'.
|
||||
|
||||
.PARAMETER RemotePath
|
||||
Absolute path on the droplet where the repo lives. Defaults to
|
||||
`/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.
|
||||
Absolute path on the droplet. Defaults to '/srv/lean101-clients'.
|
||||
|
||||
.PARAMETER EnvFile
|
||||
Local path to the env file that should land on the droplet as
|
||||
`<RemotePath>/.env.production`. Defaults to `.env.production`.
|
||||
Local path to the production env file. Defaults to '.env.production'.
|
||||
|
||||
.PARAMETER SshKey
|
||||
Optional path to an SSH private key. If omitted, the script relies on
|
||||
ssh-agent / default keys.
|
||||
Optional path to an SSH private key.
|
||||
|
||||
.PARAMETER ComposeFile
|
||||
Compose file name on the remote host. Defaults to
|
||||
`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).
|
||||
Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.
|
||||
|
||||
.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
|
||||
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
|
||||
./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
|
||||
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
|
||||
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -70,150 +47,136 @@ param(
|
||||
[Parameter(Mandatory = $true)] [string] $RemoteHost,
|
||||
[string] $RemoteUser = "root",
|
||||
[string] $RemotePath = "/srv/lean101-clients",
|
||||
[string] $Branch = "main",
|
||||
[string] $RepoUrl,
|
||||
[string] $EnvFile = ".env.production",
|
||||
[string] $SshKey,
|
||||
[string] $ComposeFile = "docker-compose.production.yml",
|
||||
[switch] $Bootstrap,
|
||||
[switch] $SkipBuild,
|
||||
[switch] $Seed,
|
||||
[switch] $Logs
|
||||
[switch] $Logs,
|
||||
[switch] $SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Write-Step($message) {
|
||||
Write-Host "==> $message" -ForegroundColor Cyan
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
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) {
|
||||
Write-Host "!! $message" -ForegroundColor Yellow
|
||||
$RepoRoot = Get-RepoRoot
|
||||
$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 {
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.ScriptName
|
||||
if (-not $scriptDir) { $scriptDir = $PSScriptRoot }
|
||||
return (Resolve-Path (Join-Path $scriptDir "..")).Path
|
||||
function Invoke-Scp([string] $local, [string] $remote) {
|
||||
& scp @SshOpts $local "${SshTarget}:${remote}"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" }
|
||||
}
|
||||
|
||||
$RepoRoot = Resolve-RepoRoot
|
||||
# ── Resolve paths ─────────────────────────────────────────────────────────────
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
|
||||
if (-not (Test-Path $envPath)) {
|
||||
throw "Env file not found at '$envPath'. Copy .env.production.example to $EnvFile and fill in production secrets first."
|
||||
$EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
|
||||
if (-not (Test-Path $EnvPath)) {
|
||||
throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets."
|
||||
}
|
||||
|
||||
$sshTarget = "$RemoteUser@$RemoteHost"
|
||||
$sshOpts = @("-o", "StrictHostKeyChecking=accept-new")
|
||||
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"
|
||||
# ── Connectivity check ──────────────────────────────────────────────────────
|
||||
Write-Step "Checking SSH connectivity to $SshTarget"
|
||||
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
|
||||
|
||||
Write-Step "Verifying Docker is installed on the droplet"
|
||||
Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version"
|
||||
# ── Package source files ────────────────────────────────────────────────────
|
||||
Write-Step "Packaging source files (excluding node_modules, caches, etc.)"
|
||||
|
||||
if ($Bootstrap) {
|
||||
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
|
||||
}
|
||||
$TarFile = Join-Path $env:TEMP "lean101-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
|
||||
|
||||
Write-Step "Uploading $EnvFile to $RemotePath/.env.production"
|
||||
Invoke-Scp $envPath "$RemotePath/.env.production"
|
||||
$excludes = @(
|
||||
"--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'"
|
||||
|
||||
$composeArgs = @(
|
||||
"--env-file", ".env.production",
|
||||
"-f", $ComposeFile
|
||||
) -join " "
|
||||
# ── Upload and extract source ────────────────────────────────────────────────
|
||||
Write-Step "Uploading source archive"
|
||||
Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz"
|
||||
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"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true"
|
||||
# ── Docker compose up ───────────────────────────────────────────────────────
|
||||
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
|
||||
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
|
||||
|
||||
Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans"
|
||||
Write-Step "Bringing stack up (build=$(-not $SkipBuild))"
|
||||
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 = @"
|
||||
set -e
|
||||
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)
|
||||
case "`$status" in
|
||||
healthy|running)
|
||||
echo "backend is `$status"
|
||||
exit 0;;
|
||||
*)
|
||||
printf '.'
|
||||
sleep 4;;
|
||||
healthy|running) echo "backend is `$status"; exit 0 ;;
|
||||
*) printf '.'; sleep 4 ;;
|
||||
esac
|
||||
done
|
||||
echo
|
||||
echo 'backend did not become healthy in time' >&2
|
||||
docker compose $composeArgs ps backend
|
||||
exit 1
|
||||
echo; echo 'backend did not become healthy in time' >&2; exit 1
|
||||
"@
|
||||
Invoke-Ssh $healthScript
|
||||
|
||||
# ── Optional seed ───────────────────────────────────────────────────────────
|
||||
if ($Seed) {
|
||||
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"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs ps"
|
||||
# ── Final status ────────────────────────────────────────────────────────────
|
||||
Write-Step "Stack status"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
|
||||
|
||||
if ($Logs) {
|
||||
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 {
|
||||
Pop-Location
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "data-entry-app-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "1.5.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"dev": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js dev",
|
||||
"build": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"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;
|
||||
}
|
||||
};
|
||||
@@ -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 = {
|
||||
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, '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),
|
||||
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
|
||||
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) =>
|
||||
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
|
||||
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">
|
||||
import { api } from '$lib/api';
|
||||
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
||||
import type { MixCalculatorSession } from '$lib/types';
|
||||
|
||||
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(
|
||||
`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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -33,118 +29,21 @@
|
||||
<section class="print-page">
|
||||
<div class="print-toolbar">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<article class="sheet">
|
||||
<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>
|
||||
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
padding: 1.5rem 1rem 2.5rem;
|
||||
background:
|
||||
linear-gradient(180deg, #eef4f0 0%, #e6eee9 100%);
|
||||
}
|
||||
|
||||
.print-toolbar {
|
||||
@@ -176,127 +75,13 @@
|
||||
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) {
|
||||
.sheet-header,
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
.print-toolbar {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.print-toolbar > :global(*) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,17 +90,13 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.print-page {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.print-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+67
-625
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
||||
import { featureFlags } from '$lib/features';
|
||||
import { clientSession, hasModuleAccess } from '$lib/session';
|
||||
import { toast } from '$lib/toast';
|
||||
@@ -10,6 +11,8 @@
|
||||
MixCalculatorPreview,
|
||||
MixCalculatorSession
|
||||
} from '$lib/types';
|
||||
import MixCalculatorPreviewModal from './MixCalculatorPreviewModal.svelte';
|
||||
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
||||
|
||||
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
||||
|
||||
@@ -51,9 +54,9 @@
|
||||
let notes = $state(initialNotesValue());
|
||||
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
|
||||
let formError = $state('');
|
||||
let formSuccess = $state('');
|
||||
let previewLoading = $state(false);
|
||||
let saveLoading = $state(false);
|
||||
let previewModalOpen = $state(false);
|
||||
|
||||
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
||||
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) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'N/A';
|
||||
@@ -119,7 +116,6 @@
|
||||
|
||||
function buildPayload(): MixCalculatorCreateInput | null {
|
||||
formError = '';
|
||||
formSuccess = '';
|
||||
|
||||
const numericBatchSize = Number(batchSizeKg);
|
||||
if (!mixDate) {
|
||||
@@ -184,7 +180,6 @@
|
||||
notes = '';
|
||||
preview = null;
|
||||
formError = '';
|
||||
formSuccess = '';
|
||||
}
|
||||
|
||||
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') {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
@@ -237,11 +263,12 @@
|
||||
{/if}
|
||||
{#if initialSession}
|
||||
<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}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="workspace-grid">
|
||||
<section class="editor-grid">
|
||||
<article class="form-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
@@ -259,9 +286,6 @@
|
||||
{#if formError}
|
||||
<p class="message error">{formError}</p>
|
||||
{/if}
|
||||
{#if formSuccess}
|
||||
<p class="message success">{formSuccess}</p>
|
||||
{/if}
|
||||
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
@@ -337,6 +361,10 @@
|
||||
</button>
|
||||
{/if}
|
||||
{: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}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
||||
<span>Print</span>
|
||||
@@ -351,202 +379,25 @@
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<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 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>
|
||||
<MixCalculatorResultsPanel
|
||||
preview={preview}
|
||||
sessionNumber={initialSession?.session_number ?? null}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#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">
|
||||
<article class="print-sheet">
|
||||
<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>
|
||||
<MixCalculatorPrintDocument session={preview} />
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -564,22 +415,10 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
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,
|
||||
.workspace-grid {
|
||||
.editor-grid {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -590,30 +429,11 @@
|
||||
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,
|
||||
.metric-card p,
|
||||
.summary-grid span,
|
||||
.calculation-note span,
|
||||
.empty-state span {
|
||||
.calculation-note span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -621,15 +441,13 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.result-card,
|
||||
.metric-card,
|
||||
.locked-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.3rem;
|
||||
@@ -638,7 +456,6 @@
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.result-card,
|
||||
.locked-card {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
@@ -665,8 +482,7 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-pill,
|
||||
.session-chip {
|
||||
.product-pill {
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
padding: 0.72rem 0.82rem;
|
||||
@@ -675,8 +491,7 @@
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.product-pill span,
|
||||
.session-chip span {
|
||||
.product-pill span {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
@@ -718,27 +533,15 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.calculation-note,
|
||||
.warning-stack,
|
||||
.empty-state {
|
||||
margin-top: 1rem;
|
||||
padding: 0.92rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.calculation-note {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.92rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.warning-stack {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
background: #fff6e6;
|
||||
color: #8b5b1e;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
@@ -751,11 +554,6 @@
|
||||
color: #b2463f;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--green-soft);
|
||||
color: var(--green-deep);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -820,259 +618,20 @@
|
||||
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) {
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-intro,
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.summary-grid {
|
||||
.field-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;
|
||||
}
|
||||
}
|
||||
|
||||
.print-only {
|
||||
@@ -1098,127 +657,10 @@
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1.4cm;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: #1a2421;
|
||||
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>
|
||||
@@ -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>
|
||||
+30
-100
@@ -28,8 +28,6 @@
|
||||
let mixVersion = $state(getInitialMix()?.version ?? 1);
|
||||
let mixNotes = $state(getInitialMix()?.notes ?? '');
|
||||
let draftIngredients = $state<DraftIngredient[]>([]);
|
||||
let feedback = $state('');
|
||||
let errorMessage = $state('');
|
||||
let isSaving = $state(false);
|
||||
|
||||
function currency(value: number | null | undefined, digits = 2) {
|
||||
@@ -86,8 +84,6 @@
|
||||
loadDraftFromMix(getInitialMix());
|
||||
|
||||
function resetDraft() {
|
||||
feedback = '';
|
||||
errorMessage = '';
|
||||
loadDraftFromMix(savedMix);
|
||||
}
|
||||
|
||||
@@ -318,14 +314,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if feedback}
|
||||
<p class="feedback success">{feedback}</p>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="feedback error">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<section class="metric-row">
|
||||
<article class="metric-card">
|
||||
<span>Live Draft Kg</span>
|
||||
@@ -567,7 +555,6 @@
|
||||
|
||||
.locked-card,
|
||||
.page-intro,
|
||||
.feedback,
|
||||
.metric-card,
|
||||
.editor-card,
|
||||
.summary-card {
|
||||
@@ -579,7 +566,6 @@
|
||||
|
||||
.locked-card,
|
||||
.page-intro,
|
||||
.feedback,
|
||||
.metric-row,
|
||||
.editor-grid {
|
||||
margin-bottom: 1.12rem;
|
||||
@@ -663,23 +649,6 @@
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
padding: 0.86rem 0.94rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback.success {
|
||||
color: var(--green-deep);
|
||||
border-color: #d8ecdf;
|
||||
background: #f6fcf8;
|
||||
}
|
||||
|
||||
.feedback.error {
|
||||
color: #a03737;
|
||||
border-color: #f0d9d9;
|
||||
background: #fff8f8;
|
||||
}
|
||||
|
||||
.metric-row,
|
||||
.editor-grid,
|
||||
.meta-grid,
|
||||
@@ -841,76 +810,54 @@
|
||||
.factor-list strong,
|
||||
.healthy-card strong {
|
||||
display: block;
|
||||
margin-bottom: 0.22rem;
|
||||
font-size: 0.94rem;
|
||||
margin-bottom: 0.28rem;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.warning-list article,
|
||||
.healthy-card {
|
||||
padding: 0.9rem 0.94rem;
|
||||
border-radius: 0.92rem;
|
||||
}
|
||||
|
||||
.warning-list article {
|
||||
border: 1px solid #f1e2c2;
|
||||
background: #fffaf2;
|
||||
color: #8d5d21;
|
||||
font-weight: 500;
|
||||
padding: 0.84rem 0.9rem;
|
||||
border: 1px solid #f0d8d8;
|
||||
border-radius: 0.92rem;
|
||||
background: #fff7f7;
|
||||
color: #9a4747;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.healthy-card {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-soft);
|
||||
padding: 0.95rem 1rem;
|
||||
border: 1px solid #d9ecdf;
|
||||
border-radius: 0.96rem;
|
||||
background: #f6fcf8;
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
@media (max-width: 980px) {
|
||||
.metric-row,
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar-stack {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@media (max-width: 720px) {
|
||||
.page-intro,
|
||||
.section-heading,
|
||||
.intro-actions,
|
||||
.editor-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.intro-actions,
|
||||
.editor-actions,
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.meta-grid,
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta-grid,
|
||||
.sidebar-stack {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sheet-table {
|
||||
min-width: 0;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.sheet-table,
|
||||
.sheet-table thead,
|
||||
.sheet-table tbody,
|
||||
@@ -920,61 +867,44 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sheet-table {
|
||||
min-width: 0;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.sheet-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet-table tbody {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sheet-table tbody tr {
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sheet-table tbody td:first-child,
|
||||
.sheet-table tbody td:last-child {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.sheet-table tbody td + td {
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sheet-table tbody td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
margin-bottom: 0.24rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sheet-table input,
|
||||
.sheet-table select,
|
||||
.icon-delete {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>© {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">
|
||||
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
|
||||
import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
|
||||
import { featureFlags } from '$lib/features';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
{#if data.session}
|
||||
<MixCalculatorWorkspace initialSession={data.session} options={data.options} />
|
||||
<MixCalculatorEditor initialSession={data.session} options={data.options} />
|
||||
{:else}
|
||||
<section class="locked-card">
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
|
||||
import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<MixCalculatorWorkspace options={data.options} />
|
||||
<MixCalculatorEditor options={data.options} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
|
||||
import MixEditor from '$lib/components/mixes/MixEditor.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<MixWorkspace rawMaterials={data.rawMaterials} initialMix={data.mix} />
|
||||
<MixEditor rawMaterials={data.rawMaterials} initialMix={data.mix} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
|
||||
import MixEditor from '$lib/components/mixes/MixEditor.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<MixWorkspace rawMaterials={data.rawMaterials} />
|
||||
<MixEditor rawMaterials={data.rawMaterials} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
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 { toast } from '$lib/toast';
|
||||
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');
|
||||
const pageSize = 20;
|
||||
let overviewMixesPage = $state(1);
|
||||
@@ -229,38 +234,18 @@
|
||||
<p class="feedback error">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<div class="workspace-layout">
|
||||
<nav class="workspace-nav" aria-label="Raw materials navigation">
|
||||
<p class="nav-section-label">Raw Materials</p>
|
||||
|
||||
<div class="nav-identity">
|
||||
<div class="nav-avatar" aria-hidden="true">
|
||||
<Wheat size={16} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div class="nav-identity-text">
|
||||
<p class="identity-name">{activeMaterials.length} active inputs</p>
|
||||
<p class="identity-role">{data.rawMaterials.length} tracked materials</p>
|
||||
</div>
|
||||
</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>
|
||||
<AppSecondaryRailLayout>
|
||||
{#snippet rail()}
|
||||
<AppSecondaryRail
|
||||
sectionLabel="Raw Materials"
|
||||
identityTitle={`${activeMaterials.length} active inputs`}
|
||||
identitySubtitle={`${data.rawMaterials.length} tracked materials`}
|
||||
identityIcon={Wheat}
|
||||
groups={railGroups}
|
||||
activeId={activeView}
|
||||
onSelect={(id) => (activeView = id as RawMaterialsView)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<div class="workspace-panel">
|
||||
{#if activeRailItem}
|
||||
@@ -620,7 +605,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppSecondaryRailLayout>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -651,7 +636,7 @@
|
||||
|
||||
.locked-card,
|
||||
.feedback,
|
||||
.workspace-layout {
|
||||
:global(.secondary-rail-layout) {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -688,82 +673,6 @@
|
||||
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 {
|
||||
padding: 0.95rem 1rem;
|
||||
font-weight: 600;
|
||||
@@ -784,96 +693,22 @@
|
||||
.metric-row,
|
||||
.top-grid,
|
||||
.material-grid,
|
||||
.impact-grid,
|
||||
.nav-group {
|
||||
.impact-grid {
|
||||
display: grid;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
@@ -913,10 +748,7 @@
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
@@ -1194,7 +1026,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.workspace-layout,
|
||||
:global(.secondary-rail-layout),
|
||||
.metric-row,
|
||||
.top-grid,
|
||||
.material-grid,
|
||||
@@ -1202,19 +1034,6 @@
|
||||
.stats-grid {
|
||||
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) {
|
||||
@@ -1239,21 +1058,5 @@
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
@@ -12,6 +14,8 @@
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
type ReportId =
|
||||
| 'sales-target-report'
|
||||
| 'finished-product-kanban'
|
||||
| 'summary'
|
||||
| 'raw-material-costs'
|
||||
| 'mix-cost-summary'
|
||||
@@ -28,6 +32,20 @@
|
||||
};
|
||||
|
||||
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',
|
||||
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]);
|
||||
</script>
|
||||
|
||||
<div class="reporting-layout">
|
||||
<nav class="report-nav" aria-label="Report navigation">
|
||||
<p class="nav-section-label">Reporting</p>
|
||||
|
||||
<div class="nav-identity">
|
||||
<div class="nav-avatar" aria-hidden="true">
|
||||
<TrendingUp size={16} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div class="nav-identity-text">
|
||||
<p class="identity-name">Workspace reports</p>
|
||||
<p class="identity-role">Costing and quality views</p>
|
||||
</div>
|
||||
</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>
|
||||
<AppSecondaryRailLayout>
|
||||
{#snippet rail()}
|
||||
<AppSecondaryRail
|
||||
sectionLabel="Reporting"
|
||||
identityTitle="Workspace reports"
|
||||
identitySubtitle="Costing and quality views"
|
||||
identityIcon={TrendingUp}
|
||||
groups={railGroups}
|
||||
activeId={activeId}
|
||||
onSelect={(id) => (activeId = id as ReportId)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<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">
|
||||
{#if activeId === 'summary'}
|
||||
<div class="report-placeholder">
|
||||
@@ -244,227 +241,127 @@
|
||||
{/each}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppSecondaryRailLayout>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem 1.35rem 1.35rem;
|
||||
}
|
||||
|
||||
.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 ───────────────────────────────────────── */
|
||||
@@ -598,43 +495,14 @@
|
||||
|
||||
/* ── 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) {
|
||||
.panel-header {
|
||||
.embed-header {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.powerbi-link {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-header-row,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { toast } from '$lib/toast';
|
||||
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
|
||||
@@ -81,37 +83,22 @@
|
||||
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
||||
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
||||
];
|
||||
|
||||
const railGroups = [{ items: navItems }];
|
||||
</script>
|
||||
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<p class="nav-section-label">Settings</p>
|
||||
|
||||
<div class="nav-identity">
|
||||
<div class="avatar" aria-hidden="true">{initials}</div>
|
||||
<div class="identity-text">
|
||||
<p class="identity-name">{$clientSession?.name ?? 'Unknown'}</p>
|
||||
<p class="identity-role">{$clientSession?.role_name ?? $clientSession?.role ?? 'User'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<AppSecondaryRailLayout>
|
||||
{#snippet rail()}
|
||||
<AppSecondaryRail
|
||||
sectionLabel="Settings"
|
||||
identityAvatarText={initials}
|
||||
identityTitle={$clientSession?.name ?? 'Unknown'}
|
||||
identitySubtitle={$clientSession?.role_name ?? $clientSession?.role ?? 'User'}
|
||||
groups={railGroups}
|
||||
activeId={activeSection}
|
||||
onSelect={(id) => (activeSection = id as Section)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<div class="settings-panel">
|
||||
{#if activeSection === 'profile'}
|
||||
@@ -180,175 +167,19 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</AppSecondaryRailLayout>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -372,10 +203,7 @@
|
||||
|
||||
.panel-form {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
padding: 1.5rem 1.75rem;
|
||||
max-width: 42rem;
|
||||
@@ -464,32 +292,6 @@
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
|
||||
@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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user