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.
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import AuthSession, require_client_module_access
|
from app.api.deps import AuthSession, require_client_module_access
|
||||||
@@ -13,14 +13,16 @@ from app.schemas.mix_calculator import (
|
|||||||
)
|
)
|
||||||
from app.services.mix_calculator_service import (
|
from app.services.mix_calculator_service import (
|
||||||
build_mix_calculator_options,
|
build_mix_calculator_options,
|
||||||
|
can_view_all_mix_calculator_sessions,
|
||||||
calculate_mix_calculator_preview,
|
calculate_mix_calculator_preview,
|
||||||
serialize_mix_calculator_session,
|
|
||||||
create_mix_calculator_session,
|
create_mix_calculator_session,
|
||||||
get_mix_calculator_session,
|
get_mix_calculator_session,
|
||||||
update_mix_calculator_session,
|
|
||||||
list_mix_calculator_sessions,
|
list_mix_calculator_sessions,
|
||||||
can_view_all_mix_calculator_sessions,
|
serialize_mix_calculator_session,
|
||||||
|
update_mix_calculator_session,
|
||||||
)
|
)
|
||||||
|
from app.services.mix_calculator_pdf import MixCalculatorPdfUnavailableError, build_mix_calculator_pdf
|
||||||
|
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
|
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
|
||||||
|
|
||||||
@@ -77,6 +79,28 @@ def read_mix_calculator_session(
|
|||||||
return serialize_mix_calculator_session(session_record, session)
|
return serialize_mix_calculator_session(session_record, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/pdf")
|
||||||
|
def download_mix_calculator_session_pdf(
|
||||||
|
session_id: int,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id)
|
||||||
|
if session_record is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = build_mix_calculator_pdf(session_record)
|
||||||
|
except MixCalculatorPdfUnavailableError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||||
|
filename = mix_calculator_pdf_filename(session_record)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{session_id}", response_model=MixCalculatorSessionRead)
|
@router.patch("/{session_id}", response_model=MixCalculatorSessionRead)
|
||||||
def patch_mix_calculator_session(
|
def patch_mix_calculator_session(
|
||||||
session_id: int,
|
session_id: int,
|
||||||
|
|||||||
+52
-6
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -22,10 +23,48 @@ from app.services.client_access_service import MODULE_CATALOG, default_access_le
|
|||||||
TENANT_ID = "hunter-premium-produce"
|
TENANT_ID = "hunter-premium-produce"
|
||||||
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
|
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
|
||||||
WORKBOOK_SENTINEL_ITEM_ID = "404266"
|
WORKBOOK_SENTINEL_ITEM_ID = "404266"
|
||||||
WORKBOOK_PATH = Path(__file__).resolve().parents[2] / "Input Cost Spreadsheet(1).xlsx"
|
WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx"
|
||||||
logger = logging.getLogger("data_entry_app.seed")
|
logger = logging.getLogger("data_entry_app.seed")
|
||||||
|
|
||||||
|
|
||||||
|
def _workbook_candidates() -> list[Path]:
|
||||||
|
env_value = os.getenv("WORKBOOK_PATH")
|
||||||
|
env_path = env_value.strip() if isinstance(env_value, str) and env_value.strip() else None
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
cwd = Path.cwd()
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
Path(env_path) if env_path else None,
|
||||||
|
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
|
||||||
|
repo_root / WORKBOOK_FILENAME,
|
||||||
|
cwd / WORKBOOK_FILENAME,
|
||||||
|
Path("/app") / WORKBOOK_FILENAME,
|
||||||
|
Path("/") / WORKBOOK_FILENAME,
|
||||||
|
]
|
||||||
|
|
||||||
|
ordered: list[Path] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
key = str(candidate)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
ordered.append(candidate)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workbook_path() -> Path:
|
||||||
|
for candidate in _workbook_candidates():
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return _workbook_candidates()[0]
|
||||||
|
|
||||||
|
|
||||||
|
WORKBOOK_PATH = _resolve_workbook_path()
|
||||||
|
|
||||||
|
|
||||||
def _text(value) -> str | None:
|
def _text(value) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -129,9 +168,12 @@ def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking
|
|||||||
|
|
||||||
|
|
||||||
def _load_workbook():
|
def _load_workbook():
|
||||||
if not WORKBOOK_PATH.exists():
|
workbook_path = _resolve_workbook_path()
|
||||||
raise FileNotFoundError(f"Workbook not found at {WORKBOOK_PATH}")
|
if not workbook_path.exists():
|
||||||
return load_workbook(WORKBOOK_PATH, data_only=True)
|
raise FileNotFoundError(
|
||||||
|
f"Workbook not found. Checked: {', '.join(str(path) for path in _workbook_candidates())}"
|
||||||
|
)
|
||||||
|
return load_workbook(workbook_path, data_only=True)
|
||||||
|
|
||||||
|
|
||||||
def _read_raw_material_rows(workbook) -> list[dict]:
|
def _read_raw_material_rows(workbook) -> list[dict]:
|
||||||
@@ -684,10 +726,14 @@ def seed_costing_workspace(db):
|
|||||||
def seed_if_empty():
|
def seed_if_empty():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if WORKBOOK_PATH.exists():
|
workbook_path = _resolve_workbook_path()
|
||||||
|
if workbook_path.exists():
|
||||||
seed_costing_workspace(db)
|
seed_costing_workspace(db)
|
||||||
else:
|
else:
|
||||||
logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH)
|
logger.warning(
|
||||||
|
"Skipping costing workspace seed because workbook is missing. Checked: %s",
|
||||||
|
", ".join(str(path) for path in _workbook_candidates()),
|
||||||
|
)
|
||||||
seed_client_access(db)
|
seed_client_access(db)
|
||||||
seed_access(db)
|
seed_access(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.models.mix_calculator import MixCalculatorSession
|
||||||
|
|
||||||
|
|
||||||
|
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
|
||||||
|
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
|
||||||
|
return re.sub(r"[^\w.\-]+", "_", raw)
|
||||||
@@ -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
|
Metadata-Version: 2.4
|
||||||
Name: data-entry-app-backend
|
Name: data-entry-app-backend
|
||||||
Version: 0.1.2
|
Version: 0.1.5
|
||||||
Summary: Costing platform MVP backend
|
Summary: Costing platform MVP backend
|
||||||
Requires-Python: >=3.11
|
Requires-Python: >=3.11
|
||||||
Requires-Dist: fastapi<1.0,>=0.115
|
Requires-Dist: fastapi<1.0,>=0.115
|
||||||
|
Requires-Dist: openpyxl<4.0,>=3.1
|
||||||
Requires-Dist: uvicorn[standard]<1.0,>=0.30
|
Requires-Dist: uvicorn[standard]<1.0,>=0.30
|
||||||
Requires-Dist: sqlalchemy<3.0,>=2.0
|
Requires-Dist: sqlalchemy<3.0,>=2.0
|
||||||
Requires-Dist: pydantic<3.0,>=2.8
|
Requires-Dist: pydantic<3.0,>=2.8
|
||||||
Requires-Dist: pytest<9.0,>=8.0
|
Requires-Dist: pytest<9.0,>=8.0
|
||||||
|
Requires-Dist: psycopg[binary]<4.0,>=3.2
|
||||||
|
Requires-Dist: reportlab<5.0,>=4.2
|
||||||
|
|||||||
@@ -2,33 +2,80 @@ pyproject.toml
|
|||||||
./app/__init__.py
|
./app/__init__.py
|
||||||
./app/main.py
|
./app/main.py
|
||||||
./app/seed.py
|
./app/seed.py
|
||||||
|
./app/seed_access.py
|
||||||
./app/api/__init__.py
|
./app/api/__init__.py
|
||||||
|
./app/api/access.py
|
||||||
|
./app/api/auth.py
|
||||||
|
./app/api/client_access.py
|
||||||
|
./app/api/dashboard.py
|
||||||
|
./app/api/deps.py
|
||||||
|
./app/api/mix_calculator.py
|
||||||
./app/api/mixes.py
|
./app/api/mixes.py
|
||||||
./app/api/powerbi.py
|
./app/api/powerbi.py
|
||||||
./app/api/products.py
|
./app/api/products.py
|
||||||
./app/api/raw_materials.py
|
./app/api/raw_materials.py
|
||||||
./app/api/scenarios.py
|
./app/api/scenarios.py
|
||||||
./app/core/__init__.py
|
./app/core/__init__.py
|
||||||
|
./app/core/access.py
|
||||||
./app/core/config.py
|
./app/core/config.py
|
||||||
|
./app/core/security.py
|
||||||
./app/db/__init__.py
|
./app/db/__init__.py
|
||||||
|
./app/db/migrations.py
|
||||||
./app/db/session.py
|
./app/db/session.py
|
||||||
./app/models/__init__.py
|
./app/models/__init__.py
|
||||||
|
./app/models/access.py
|
||||||
./app/models/assumption.py
|
./app/models/assumption.py
|
||||||
|
./app/models/client_access.py
|
||||||
./app/models/mix.py
|
./app/models/mix.py
|
||||||
|
./app/models/mix_calculator.py
|
||||||
./app/models/product.py
|
./app/models/product.py
|
||||||
./app/models/raw_material.py
|
./app/models/raw_material.py
|
||||||
./app/models/scenario.py
|
./app/models/scenario.py
|
||||||
./app/schemas/__init__.py
|
./app/schemas/__init__.py
|
||||||
|
./app/schemas/client_access.py
|
||||||
./app/schemas/mix.py
|
./app/schemas/mix.py
|
||||||
|
./app/schemas/mix_calculator.py
|
||||||
./app/schemas/product.py
|
./app/schemas/product.py
|
||||||
./app/schemas/raw_material.py
|
./app/schemas/raw_material.py
|
||||||
./app/schemas/scenario.py
|
./app/schemas/scenario.py
|
||||||
./app/services/__init__.py
|
./app/services/__init__.py
|
||||||
|
./app/services/client_access_service.py
|
||||||
./app/services/costing_engine.py
|
./app/services/costing_engine.py
|
||||||
|
./app/services/mix_calculator_filenames.py
|
||||||
|
./app/services/mix_calculator_pdf.py
|
||||||
|
./app/services/mix_calculator_service.py
|
||||||
./app/services/scenario_engine.py
|
./app/services/scenario_engine.py
|
||||||
|
app/__init__.py
|
||||||
|
app/main.py
|
||||||
|
app/seed.py
|
||||||
|
app/api/__init__.py
|
||||||
|
app/api/mixes.py
|
||||||
|
app/api/powerbi.py
|
||||||
|
app/api/products.py
|
||||||
|
app/api/raw_materials.py
|
||||||
|
app/api/scenarios.py
|
||||||
|
app/core/__init__.py
|
||||||
|
app/core/config.py
|
||||||
|
app/db/__init__.py
|
||||||
|
app/db/session.py
|
||||||
|
app/models/__init__.py
|
||||||
|
app/models/assumption.py
|
||||||
|
app/models/mix.py
|
||||||
|
app/models/product.py
|
||||||
|
app/models/raw_material.py
|
||||||
|
app/models/scenario.py
|
||||||
|
app/schemas/__init__.py
|
||||||
|
app/schemas/mix.py
|
||||||
|
app/schemas/product.py
|
||||||
|
app/schemas/raw_material.py
|
||||||
|
app/schemas/scenario.py
|
||||||
|
app/services/__init__.py
|
||||||
|
app/services/costing_engine.py
|
||||||
|
app/services/scenario_engine.py
|
||||||
data_entry_app_backend.egg-info/PKG-INFO
|
data_entry_app_backend.egg-info/PKG-INFO
|
||||||
data_entry_app_backend.egg-info/SOURCES.txt
|
data_entry_app_backend.egg-info/SOURCES.txt
|
||||||
data_entry_app_backend.egg-info/dependency_links.txt
|
data_entry_app_backend.egg-info/dependency_links.txt
|
||||||
data_entry_app_backend.egg-info/requires.txt
|
data_entry_app_backend.egg-info/requires.txt
|
||||||
data_entry_app_backend.egg-info/top_level.txt
|
data_entry_app_backend.egg-info/top_level.txt
|
||||||
|
tests/test_access.py
|
||||||
tests/test_costing_engine.py
|
tests/test_costing_engine.py
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
fastapi<1.0,>=0.115
|
fastapi<1.0,>=0.115
|
||||||
|
openpyxl<4.0,>=3.1
|
||||||
uvicorn[standard]<1.0,>=0.30
|
uvicorn[standard]<1.0,>=0.30
|
||||||
sqlalchemy<3.0,>=2.0
|
sqlalchemy<3.0,>=2.0
|
||||||
pydantic<3.0,>=2.8
|
pydantic<3.0,>=2.8
|
||||||
pytest<9.0,>=8.0
|
pytest<9.0,>=8.0
|
||||||
|
psycopg[binary]<4.0,>=3.2
|
||||||
|
reportlab<5.0,>=4.2
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"pydantic>=2.8,<3.0",
|
"pydantic>=2.8,<3.0",
|
||||||
"pytest>=8.0,<9.0",
|
"pytest>=8.0,<9.0",
|
||||||
"psycopg[binary]>=3.2,<4.0",
|
"psycopg[binary]>=3.2,<4.0",
|
||||||
|
"reportlab>=4.2,<5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
|
|||||||
@@ -344,6 +344,41 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
assert operator_detail_response.status_code == 404
|
assert operator_detail_response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_mix_calculator_pdf_endpoint_returns_pdf():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
superadmin_login = client.post(
|
||||||
|
"/api/auth/client/login",
|
||||||
|
json={"email": settings.client_email, "password": settings.client_password},
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"}
|
||||||
|
|
||||||
|
options_response = client.get("/api/mix-calculator/options", headers=headers)
|
||||||
|
seeded_product = next(
|
||||||
|
product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg"
|
||||||
|
)
|
||||||
|
|
||||||
|
create_response = client.post(
|
||||||
|
"/api/mix-calculator",
|
||||||
|
json={
|
||||||
|
"mix_date": "2026-04-29",
|
||||||
|
"client_name": seeded_product["client_name"],
|
||||||
|
"product_id": seeded_product["product_id"],
|
||||||
|
"batch_size_kg": 560,
|
||||||
|
"prepared_by_name": "Amelia Hart",
|
||||||
|
"notes": "Morning production run",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
created = create_response.json()
|
||||||
|
|
||||||
|
pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", headers=headers)
|
||||||
|
|
||||||
|
assert pdf_response.status_code == 200
|
||||||
|
assert pdf_response.headers["content-type"] == "application/pdf"
|
||||||
|
assert "attachment;" in pdf_response.headers["content-disposition"]
|
||||||
|
assert pdf_response.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
def test_module_permission_blocks_client_module_access():
|
def test_module_permission_blocks_client_module_access():
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
admin_login_response = client.post(
|
admin_login_response = client.post(
|
||||||
|
|||||||
+97
-134
@@ -1,68 +1,45 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
|
Deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Runs `docker compose` against `docker-compose.production.yml` on the remote
|
Tars the local source tree, uploads it to the droplet, and runs
|
||||||
host. The same script handles first-time bootstrap and subsequent updates:
|
docker compose up --build. No git required on the server.
|
||||||
|
|
||||||
* On bootstrap (-Bootstrap): creates the remote directory, clones the
|
The same script handles first-time setup and subsequent updates.
|
||||||
repo (or updates if already present), uploads the local env file, and
|
|
||||||
brings the stack up with `docker compose ... up -d --build`.
|
|
||||||
|
|
||||||
* On update (default): SSHes to the host, fetches the requested branch,
|
|
||||||
uploads a refreshed env file (if changed), then runs
|
|
||||||
`docker compose ... up -d --build` followed by a healthcheck.
|
|
||||||
|
|
||||||
The script never executes destructive commands without asking, except for
|
|
||||||
recreating containers (which preserves the named Postgres volume).
|
|
||||||
|
|
||||||
.PARAMETER RemoteHost
|
.PARAMETER RemoteHost
|
||||||
Hostname or IP of the Digital Ocean droplet. Required.
|
Hostname or IP of the Digital Ocean droplet. Required.
|
||||||
|
|
||||||
.PARAMETER RemoteUser
|
.PARAMETER RemoteUser
|
||||||
SSH user on the droplet. Defaults to `root`.
|
SSH user. Defaults to 'root'.
|
||||||
|
|
||||||
.PARAMETER RemotePath
|
.PARAMETER RemotePath
|
||||||
Absolute path on the droplet where the repo lives. Defaults to
|
Absolute path on the droplet. Defaults to '/srv/lean101-clients'.
|
||||||
`/srv/lean101-clients`.
|
|
||||||
|
|
||||||
.PARAMETER Branch
|
|
||||||
Git branch to deploy. Defaults to `main`.
|
|
||||||
|
|
||||||
.PARAMETER RepoUrl
|
|
||||||
Git URL used during bootstrap when the remote directory is empty.
|
|
||||||
Required only with -Bootstrap.
|
|
||||||
|
|
||||||
.PARAMETER EnvFile
|
.PARAMETER EnvFile
|
||||||
Local path to the env file that should land on the droplet as
|
Local path to the production env file. Defaults to '.env.production'.
|
||||||
`<RemotePath>/.env.production`. Defaults to `.env.production`.
|
|
||||||
|
|
||||||
.PARAMETER SshKey
|
.PARAMETER SshKey
|
||||||
Optional path to an SSH private key. If omitted, the script relies on
|
Optional path to an SSH private key.
|
||||||
ssh-agent / default keys.
|
|
||||||
|
|
||||||
.PARAMETER ComposeFile
|
.PARAMETER ComposeFile
|
||||||
Compose file name on the remote host. Defaults to
|
Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.
|
||||||
`docker-compose.production.yml`.
|
|
||||||
|
|
||||||
.PARAMETER Bootstrap
|
|
||||||
Run first-time setup (clone, upload env, build, up).
|
|
||||||
|
|
||||||
.PARAMETER SkipBuild
|
|
||||||
Pass `--no-build` to docker compose (use when only env changed).
|
|
||||||
|
|
||||||
.PARAMETER Seed
|
.PARAMETER Seed
|
||||||
Run `python -m app.seed` inside the backend container after the stack is up.
|
Run 'python -m app.seed' inside the backend container after the stack is up.
|
||||||
|
|
||||||
.PARAMETER Logs
|
.PARAMETER Logs
|
||||||
After deploy, tail logs for ~20 lines so you can verify the stack came up.
|
Tail logs for ~60 lines after deploy to verify the stack came up.
|
||||||
|
|
||||||
|
.PARAMETER SkipBuild
|
||||||
|
Pass --no-build to docker compose (use when only env changed).
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git
|
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
|
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
@@ -70,150 +47,136 @@ param(
|
|||||||
[Parameter(Mandatory = $true)] [string] $RemoteHost,
|
[Parameter(Mandatory = $true)] [string] $RemoteHost,
|
||||||
[string] $RemoteUser = "root",
|
[string] $RemoteUser = "root",
|
||||||
[string] $RemotePath = "/srv/lean101-clients",
|
[string] $RemotePath = "/srv/lean101-clients",
|
||||||
[string] $Branch = "main",
|
|
||||||
[string] $RepoUrl,
|
|
||||||
[string] $EnvFile = ".env.production",
|
[string] $EnvFile = ".env.production",
|
||||||
[string] $SshKey,
|
[string] $SshKey,
|
||||||
[string] $ComposeFile = "docker-compose.production.yml",
|
[string] $ComposeFile = "docker-compose.production.yml",
|
||||||
[switch] $Bootstrap,
|
|
||||||
[switch] $SkipBuild,
|
|
||||||
[switch] $Seed,
|
[switch] $Seed,
|
||||||
[switch] $Logs
|
[switch] $Logs,
|
||||||
|
[switch] $SkipBuild
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
function Write-Step($message) {
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
Write-Host "==> $message" -ForegroundColor Cyan
|
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||||
|
function Write-Warn($msg) { Write-Host "!! $msg" -ForegroundColor Yellow }
|
||||||
|
|
||||||
|
function Get-RepoRoot {
|
||||||
|
$dir = Split-Path -Parent $PSScriptRoot
|
||||||
|
if (-not $dir) { $dir = (Get-Location).Path }
|
||||||
|
return $dir
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-Warn($message) {
|
$RepoRoot = Get-RepoRoot
|
||||||
Write-Host "!! $message" -ForegroundColor Yellow
|
$SshTarget = "$RemoteUser@$RemoteHost"
|
||||||
|
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no")
|
||||||
|
if ($SshKey) { $SshOpts += @("-i", $SshKey) }
|
||||||
|
|
||||||
|
function Invoke-Ssh([string] $cmd) {
|
||||||
|
& ssh @SshOpts $SshTarget $cmd
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Remote command failed (exit $LASTEXITCODE): $cmd" }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Resolve-RepoRoot {
|
function Invoke-Scp([string] $local, [string] $remote) {
|
||||||
$scriptDir = Split-Path -Parent $MyInvocation.ScriptName
|
& scp @SshOpts $local "${SshTarget}:${remote}"
|
||||||
if (-not $scriptDir) { $scriptDir = $PSScriptRoot }
|
if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" }
|
||||||
return (Resolve-Path (Join-Path $scriptDir "..")).Path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$RepoRoot = Resolve-RepoRoot
|
# ── Resolve paths ─────────────────────────────────────────────────────────────
|
||||||
Push-Location $RepoRoot
|
Push-Location $RepoRoot
|
||||||
try {
|
try {
|
||||||
$envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
|
$EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
|
||||||
if (-not (Test-Path $envPath)) {
|
if (-not (Test-Path $EnvPath)) {
|
||||||
throw "Env file not found at '$envPath'. Copy .env.production.example to $EnvFile and fill in production secrets first."
|
throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets."
|
||||||
}
|
}
|
||||||
|
|
||||||
$sshTarget = "$RemoteUser@$RemoteHost"
|
# ── Connectivity check ──────────────────────────────────────────────────────
|
||||||
$sshOpts = @("-o", "StrictHostKeyChecking=accept-new")
|
Write-Step "Checking SSH connectivity to $SshTarget"
|
||||||
if ($SshKey) { $sshOpts += @("-i", $SshKey) }
|
|
||||||
|
|
||||||
function Invoke-Ssh([string] $remoteCommand) {
|
|
||||||
& ssh @sshOpts $sshTarget $remoteCommand
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Remote command failed (exit $LASTEXITCODE): $remoteCommand"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-Scp([string] $localPath, [string] $remoteDest) {
|
|
||||||
& scp @sshOpts $localPath "$($sshTarget):$remoteDest"
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "scp failed for $localPath -> $remoteDest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Step "Verifying SSH connectivity to $sshTarget"
|
|
||||||
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
|
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
|
||||||
|
|
||||||
Write-Step "Verifying Docker is installed on the droplet"
|
# ── Package source files ────────────────────────────────────────────────────
|
||||||
Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version"
|
Write-Step "Packaging source files (excluding node_modules, caches, etc.)"
|
||||||
|
|
||||||
if ($Bootstrap) {
|
$TarFile = Join-Path $env:TEMP "lean101-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
|
||||||
if (-not $RepoUrl) {
|
|
||||||
throw "-RepoUrl is required when using -Bootstrap."
|
|
||||||
}
|
|
||||||
Write-Step "Bootstrapping $RemotePath from $RepoUrl ($Branch)"
|
|
||||||
$bootstrapScript = @"
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p '$RemotePath'
|
|
||||||
cd '$RemotePath'
|
|
||||||
if [ ! -d .git ]; then
|
|
||||||
git clone --branch '$Branch' '$RepoUrl' .
|
|
||||||
else
|
|
||||||
git remote set-url origin '$RepoUrl'
|
|
||||||
git fetch origin '$Branch'
|
|
||||||
git checkout '$Branch'
|
|
||||||
git reset --hard 'origin/$Branch'
|
|
||||||
fi
|
|
||||||
"@
|
|
||||||
Invoke-Ssh $bootstrapScript
|
|
||||||
} else {
|
|
||||||
Write-Step "Updating $RemotePath to latest $Branch"
|
|
||||||
$updateScript = @"
|
|
||||||
set -euo pipefail
|
|
||||||
cd '$RemotePath'
|
|
||||||
git fetch origin '$Branch'
|
|
||||||
git checkout '$Branch'
|
|
||||||
git reset --hard 'origin/$Branch'
|
|
||||||
"@
|
|
||||||
Invoke-Ssh $updateScript
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Step "Uploading $EnvFile to $RemotePath/.env.production"
|
$excludes = @(
|
||||||
Invoke-Scp $envPath "$RemotePath/.env.production"
|
"--exclude=./node_modules",
|
||||||
|
"--exclude=./frontend/node_modules",
|
||||||
|
"--exclude=./frontend/.svelte-kit",
|
||||||
|
"--exclude=./frontend/build",
|
||||||
|
"--exclude=./.git",
|
||||||
|
"--exclude=./__pycache__",
|
||||||
|
"--exclude=./backend/__pycache__",
|
||||||
|
"--exclude=./backend/app/__pycache__",
|
||||||
|
"--exclude=./**/__pycache__",
|
||||||
|
"--exclude=./*.pyc",
|
||||||
|
"--exclude=./.env",
|
||||||
|
"--exclude=./.env.production",
|
||||||
|
"--exclude=./.env.alpha",
|
||||||
|
"--exclude=./data_entry_app.db",
|
||||||
|
"--exclude=./*.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
& tar -czf $TarFile @excludes -C $RepoRoot .
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tar failed" }
|
||||||
|
|
||||||
|
$TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 1)
|
||||||
|
Write-Host " Archive: $TarFile ($TarSize MB)"
|
||||||
|
|
||||||
|
# ── Upload env file ─────────────────────────────────────────────────────────
|
||||||
|
Write-Step "Uploading env file"
|
||||||
|
Invoke-Scp $EnvPath "$RemotePath/.env.production"
|
||||||
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
|
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
|
||||||
|
|
||||||
$composeArgs = @(
|
# ── Upload and extract source ────────────────────────────────────────────────
|
||||||
"--env-file", ".env.production",
|
Write-Step "Uploading source archive"
|
||||||
"-f", $ComposeFile
|
Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz"
|
||||||
) -join " "
|
Remove-Item $TarFile -Force
|
||||||
|
|
||||||
$buildFlag = if ($SkipBuild) { "" } else { "--build" }
|
Write-Step "Extracting on server"
|
||||||
|
Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/lean101-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-deploy.tar.gz"
|
||||||
|
|
||||||
Write-Step "Pulling base images"
|
# ── Docker compose up ───────────────────────────────────────────────────────
|
||||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true"
|
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
|
||||||
|
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
|
||||||
|
|
||||||
Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))"
|
Write-Step "Bringing stack up (build=$(-not $SkipBuild))"
|
||||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans"
|
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
|
||||||
|
|
||||||
|
# ── Health check ────────────────────────────────────────────────────────────
|
||||||
Write-Step "Waiting for backend health check"
|
Write-Step "Waiting for backend health check"
|
||||||
$healthScript = @"
|
$healthScript = @"
|
||||||
set -e
|
set -e
|
||||||
cd '$RemotePath'
|
cd '$RemotePath'
|
||||||
for attempt in `$(seq 1 30); do
|
for i in `$(seq 1 30); do
|
||||||
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing)
|
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing)
|
||||||
case "`$status" in
|
case "`$status" in
|
||||||
healthy|running)
|
healthy|running) echo "backend is `$status"; exit 0 ;;
|
||||||
echo "backend is `$status"
|
*) printf '.'; sleep 4 ;;
|
||||||
exit 0;;
|
|
||||||
*)
|
|
||||||
printf '.'
|
|
||||||
sleep 4;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
echo
|
echo; echo 'backend did not become healthy in time' >&2; exit 1
|
||||||
echo 'backend did not become healthy in time' >&2
|
|
||||||
docker compose $composeArgs ps backend
|
|
||||||
exit 1
|
|
||||||
"@
|
"@
|
||||||
Invoke-Ssh $healthScript
|
Invoke-Ssh $healthScript
|
||||||
|
|
||||||
|
# ── Optional seed ───────────────────────────────────────────────────────────
|
||||||
if ($Seed) {
|
if ($Seed) {
|
||||||
Write-Step "Seeding reference data"
|
Write-Step "Seeding reference data"
|
||||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs exec -T backend python -m app.seed"
|
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Step "Final container status"
|
# ── Final status ────────────────────────────────────────────────────────────
|
||||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs ps"
|
Write-Step "Stack status"
|
||||||
|
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
|
||||||
|
|
||||||
if ($Logs) {
|
if ($Logs) {
|
||||||
Write-Step "Recent logs (last 60 lines)"
|
Write-Step "Recent logs (last 60 lines)"
|
||||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs logs --tail=60"
|
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Deployment complete." -ForegroundColor Green
|
Write-Host ""
|
||||||
|
Write-Host "Deployment complete -> https://clients.lean-101.com.au" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|||||||
@@ -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",
|
"name": "data-entry-app-frontend",
|
||||||
"version": "0.2.0",
|
"version": "1.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js dev",
|
||||||
"build": "vite build",
|
"build": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js build",
|
||||||
"preview": "vite preview",
|
"preview": "node -r ./scripts/vite-windows-eperm-workaround.cjs ./node_modules/vite/bin/vite.js preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const childProcess = require('node:child_process');
|
||||||
|
|
||||||
|
const originalExec = childProcess.exec;
|
||||||
|
|
||||||
|
childProcess.exec = function patchedExec(command, options, callback) {
|
||||||
|
const normalizedCommand = typeof command === 'string' ? command.trim().toLowerCase() : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return originalExec.call(this, command, options, callback);
|
||||||
|
} catch (error) {
|
||||||
|
if (normalizedCommand === 'net use' && error && error.code === 'EPERM') {
|
||||||
|
const cb =
|
||||||
|
typeof options === 'function'
|
||||||
|
? options
|
||||||
|
: typeof callback === 'function'
|
||||||
|
? callback
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (cb) {
|
||||||
|
process.nextTick(() => cb(error, '', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pid: undefined,
|
||||||
|
killed: false,
|
||||||
|
kill() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
on() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
once() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
emit() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
removeListener() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -224,6 +224,34 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestBlob(
|
||||||
|
path: string,
|
||||||
|
auth: AuthMode = 'none',
|
||||||
|
fetcher: ApiFetch = fetch
|
||||||
|
): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const token = getToken(auth);
|
||||||
|
const response = await fetcher(buildApiUrl(path), {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Request failed';
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { detail?: string };
|
||||||
|
message = body.detail ?? message;
|
||||||
|
} catch {
|
||||||
|
message = response.statusText || message;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.blob();
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeRequestError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
|
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
|
||||||
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
|
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
|
||||||
@@ -234,6 +262,8 @@ export const api = {
|
|||||||
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
|
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
|
||||||
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
|
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
|
||||||
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
|
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
|
||||||
|
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
|
||||||
|
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, 'client', fetcher),
|
||||||
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
||||||
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
|
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
session,
|
||||||
|
generatedAt = null,
|
||||||
|
showGeneratedStamp = true
|
||||||
|
}: {
|
||||||
|
session: MixCalculatorPreview | MixCalculatorSession;
|
||||||
|
generatedAt?: string | null;
|
||||||
|
showGeneratedStamp?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('en-NZ', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: undefined
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('en-NZ', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number, digits = 2) {
|
||||||
|
return value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSessionNumber(value: MixCalculatorPreview | MixCalculatorSession): value is MixCalculatorSession {
|
||||||
|
return 'session_number' in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionNumber = $derived(hasSessionNumber(session) ? session.session_number : null);
|
||||||
|
const issuedAt = $derived(generatedAt ?? new Date().toISOString());
|
||||||
|
const blendTotal = $derived(session.lines.reduce((sum, line) => sum + line.mix_percentage, 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="print-document">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<div class="hero-kicker">
|
||||||
|
<span>Mix Calculator</span>
|
||||||
|
{#if sessionNumber}
|
||||||
|
<strong>{sessionNumber}</strong>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<h1>{session.product_name}</h1>
|
||||||
|
<p>{session.client_name} · {session.mix_name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-side">
|
||||||
|
<div>
|
||||||
|
<span>Mix date</span>
|
||||||
|
<strong>{formatDate(session.mix_date)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Prepared by</span>
|
||||||
|
<strong>{session.prepared_by_name}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Status</span>
|
||||||
|
<strong>{session.status}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="summary-band" aria-label="Session summary">
|
||||||
|
<article>
|
||||||
|
<span>Batch size</span>
|
||||||
|
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>Total output</span>
|
||||||
|
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>Bags</span>
|
||||||
|
<strong>{formatNumber(session.total_bags, 2)}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>Unit pack</span>
|
||||||
|
<strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-grid">
|
||||||
|
<article class="detail-card">
|
||||||
|
<span>Mix source</span>
|
||||||
|
<strong>{session.mix_name}</strong>
|
||||||
|
<p>Saved against {session.product_unit_of_measure} units.</p>
|
||||||
|
</article>
|
||||||
|
<article class="detail-card">
|
||||||
|
<span>Composition</span>
|
||||||
|
<strong>{formatNumber(blendTotal, 2)}%</strong>
|
||||||
|
<p>{session.lines.length} raw material{session.lines.length === 1 ? '' : 's'} in the blend.</p>
|
||||||
|
</article>
|
||||||
|
{#if showGeneratedStamp}
|
||||||
|
<article class="detail-card">
|
||||||
|
<span>Generated</span>
|
||||||
|
<strong>{formatTimestamp(issuedAt)}</strong>
|
||||||
|
<p>Prepared for print or PDF export.</p>
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if session.notes}
|
||||||
|
<section class="callout notes">
|
||||||
|
<span>Notes</span>
|
||||||
|
<p>{session.notes}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if session.warnings.length}
|
||||||
|
<section class="callout warning">
|
||||||
|
<span>Warnings</span>
|
||||||
|
<ul>
|
||||||
|
{#each session.warnings as warning}
|
||||||
|
<li>{warning}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="composition-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span>Required Raw Materials</span>
|
||||||
|
<h2>Blend composition</h2>
|
||||||
|
</div>
|
||||||
|
<p>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raw material</th>
|
||||||
|
<th>Mix %</th>
|
||||||
|
<th>Required kg</th>
|
||||||
|
<th>Unit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each session.lines as line}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{line.raw_material_name}</strong>
|
||||||
|
</td>
|
||||||
|
<td>{formatNumber(line.mix_percentage, 2)}%</td>
|
||||||
|
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
||||||
|
<td>{line.unit}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(:root) {
|
||||||
|
--print-page-width: 210mm;
|
||||||
|
--print-page-height: 297mm;
|
||||||
|
--print-page-padding-x: 14mm;
|
||||||
|
--print-page-padding-y: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p,
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-document {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.4rem;
|
||||||
|
width: min(100%, var(--print-page-width));
|
||||||
|
min-height: var(--print-page-height);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
||||||
|
border: 1px solid #dbe4de;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(21, 128, 61, 0.08), transparent 22rem),
|
||||||
|
linear-gradient(180deg, #fff 0%, #fbfcfb 100%);
|
||||||
|
color: #21312a;
|
||||||
|
box-shadow:
|
||||||
|
0 28px 48px rgba(21, 33, 26, 0.08),
|
||||||
|
0 0 0 1px rgba(219, 228, 222, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 17rem;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
padding-bottom: 1.3rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--line) 88%, #dce7df);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: #62736b;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker strong {
|
||||||
|
padding: 0.36rem 0.55rem;
|
||||||
|
border: 1px solid #d5dfd9;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #214233;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
max-width: 11ch;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.3rem);
|
||||||
|
line-height: 0.96;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy p,
|
||||||
|
.section-heading p,
|
||||||
|
.detail-card p {
|
||||||
|
color: #6b7a73;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy p {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-side,
|
||||||
|
.summary-band,
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-side div,
|
||||||
|
.summary-band article,
|
||||||
|
.detail-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-side span,
|
||||||
|
.summary-band span,
|
||||||
|
.detail-card span,
|
||||||
|
.callout span,
|
||||||
|
th,
|
||||||
|
.section-heading span {
|
||||||
|
color: #6b7a73;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-side strong,
|
||||||
|
.detail-card strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-band {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-band article {
|
||||||
|
min-height: 6.2rem;
|
||||||
|
padding: 1rem 1.05rem;
|
||||||
|
border: 1px solid #dfe7e2;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-band strong {
|
||||||
|
margin-top: auto;
|
||||||
|
font-size: clamp(1.4rem, 2.4vw, 2rem);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
min-height: 7rem;
|
||||||
|
padding: 1rem 1.05rem;
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
background: #f6f9f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout.notes {
|
||||||
|
background: #f6f9f7;
|
||||||
|
border: 1px solid #dfe7e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout.warning {
|
||||||
|
background: #fff7ea;
|
||||||
|
border: 1px solid #f0cf97;
|
||||||
|
color: #82561b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout ul {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composition-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2 {
|
||||||
|
margin-top: 0.32rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1px solid #dfe7e2;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e6ede9;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
display: table-header-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
td strong {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #203128;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero,
|
||||||
|
.summary-band,
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
:global(html),
|
||||||
|
:global(body) {
|
||||||
|
width: var(--print-page-width);
|
||||||
|
min-height: var(--print-page-height);
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-document {
|
||||||
|
width: var(--print-page-width);
|
||||||
|
min-height: var(--print-page-height);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e2622;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-band article,
|
||||||
|
.detail-card,
|
||||||
|
table,
|
||||||
|
.callout {
|
||||||
|
border-color: #d5ddd8;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout.warning {
|
||||||
|
background: #fff8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4 portrait;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
||||||
import type { MixCalculatorSession } from '$lib/types';
|
import type { MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
let { session }: { session: MixCalculatorSession } = $props();
|
let { session }: { session: MixCalculatorSession } = $props();
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Intl.DateTimeFormat('en-NZ', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: undefined
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(value: string) {
|
|
||||||
return new Intl.DateTimeFormat('en-NZ', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short'
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number, digits = 2) {
|
|
||||||
return value.toFixed(digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
const printableTitle = $derived(
|
const printableTitle = $derived(
|
||||||
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
|
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function downloadPdf() {
|
||||||
|
const blob = await api.mixCalculatorSessionPdf(session.id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `${printableTitle}.pdf`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -33,118 +29,21 @@
|
|||||||
<section class="print-page">
|
<section class="print-page">
|
||||||
<div class="print-toolbar">
|
<div class="print-toolbar">
|
||||||
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
|
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
|
||||||
|
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
||||||
<button class="primary-button" type="button" onclick={() => window.print()}>Print / Save PDF</button>
|
<button class="primary-button" type="button" onclick={() => window.print()}>Print / Save PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article class="sheet">
|
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
|
||||||
<header class="sheet-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Mix Calculator</p>
|
|
||||||
<h1>{session.session_number}</h1>
|
|
||||||
<p>Generated from the saved session snapshot without re-reading live product or recipe data.</p>
|
|
||||||
</div>
|
|
||||||
<div class="sheet-meta">
|
|
||||||
<div>
|
|
||||||
<span>Generated</span>
|
|
||||||
<strong>{formatTimestamp(new Date().toISOString())}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Status</span>
|
|
||||||
<strong>{session.status}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="summary-grid">
|
|
||||||
<div>
|
|
||||||
<span>Date</span>
|
|
||||||
<strong>{formatDate(session.mix_date)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Client</span>
|
|
||||||
<strong>{session.client_name}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Product</span>
|
|
||||||
<strong>{session.product_name}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Mix source</span>
|
|
||||||
<strong>{session.mix_name}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Batch size</span>
|
|
||||||
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Total bags</span>
|
|
||||||
<strong>{formatNumber(session.total_bags, 2)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Total kilograms</span>
|
|
||||||
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Prepared by</span>
|
|
||||||
<strong>{session.prepared_by_name}</strong>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if session.notes}
|
|
||||||
<section class="notes-card">
|
|
||||||
<h2>Session notes</h2>
|
|
||||||
<p>{session.notes}</p>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if session.warnings.length}
|
|
||||||
<section class="warning-card">
|
|
||||||
<h2>Warnings</h2>
|
|
||||||
{#each session.warnings as warning}
|
|
||||||
<p>{warning}</p>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="table-card">
|
|
||||||
<div class="table-header">
|
|
||||||
<h2>Required raw materials</h2>
|
|
||||||
<span>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</span>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Raw material</th>
|
|
||||||
<th>Mix %</th>
|
|
||||||
<th>Required kg</th>
|
|
||||||
<th>Unit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each session.lines as line}
|
|
||||||
<tr>
|
|
||||||
<td>{line.raw_material_name}</td>
|
|
||||||
<td>{formatNumber(line.mix_percentage, 2)}%</td>
|
|
||||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
|
||||||
<td>{line.unit}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-page {
|
.print-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 1.5rem 1rem 2.5rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #eef4f0 0%, #e6eee9 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-toolbar {
|
.print-toolbar {
|
||||||
@@ -176,127 +75,13 @@
|
|||||||
color: #304038;
|
color: #304038;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet {
|
|
||||||
width: min(960px, 100%);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
color: #7d8d84;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding-bottom: 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-header h1 {
|
|
||||||
margin: 0.3rem 0 0.45rem;
|
|
||||||
font-size: clamp(2rem, 4vw, 2.6rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-header p:last-child,
|
|
||||||
.sheet-meta span,
|
|
||||||
.summary-grid span,
|
|
||||||
.table-header span {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-meta {
|
|
||||||
min-width: 14rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-meta div,
|
|
||||||
.summary-grid div {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.35rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes-card,
|
|
||||||
.warning-card,
|
|
||||||
.table-card {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes-card,
|
|
||||||
.warning-card {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes-card {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-card {
|
|
||||||
background: #fff6e6;
|
|
||||||
color: #8b5b1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-card h2,
|
|
||||||
.notes-card h2,
|
|
||||||
.table-header h2 {
|
|
||||||
margin-bottom: 0.45rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0.88rem 0.75rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.sheet-header,
|
.print-toolbar {
|
||||||
.table-header {
|
justify-content: stretch;
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.print-toolbar > :global(*) {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,17 +90,13 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.print-page {
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.print-toolbar {
|
.print-toolbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+67
-625
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
||||||
import { featureFlags } from '$lib/features';
|
import { featureFlags } from '$lib/features';
|
||||||
import { clientSession, hasModuleAccess } from '$lib/session';
|
import { clientSession, hasModuleAccess } from '$lib/session';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
@@ -10,6 +11,8 @@
|
|||||||
MixCalculatorPreview,
|
MixCalculatorPreview,
|
||||||
MixCalculatorSession
|
MixCalculatorSession
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
import MixCalculatorPreviewModal from './MixCalculatorPreviewModal.svelte';
|
||||||
|
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
||||||
|
|
||||||
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
||||||
|
|
||||||
@@ -51,9 +54,9 @@
|
|||||||
let notes = $state(initialNotesValue());
|
let notes = $state(initialNotesValue());
|
||||||
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
|
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
let formSuccess = $state('');
|
|
||||||
let previewLoading = $state(false);
|
let previewLoading = $state(false);
|
||||||
let saveLoading = $state(false);
|
let saveLoading = $state(false);
|
||||||
|
let previewModalOpen = $state(false);
|
||||||
|
|
||||||
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
||||||
const isExistingSession = $derived(initialSession !== null);
|
const isExistingSession = $derived(initialSession !== null);
|
||||||
@@ -103,12 +106,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Intl.DateTimeFormat('en-NZ', {
|
|
||||||
dateStyle: 'medium'
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined, digits = 2) {
|
function formatNumber(value: number | null | undefined, digits = 2) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -119,7 +116,6 @@
|
|||||||
|
|
||||||
function buildPayload(): MixCalculatorCreateInput | null {
|
function buildPayload(): MixCalculatorCreateInput | null {
|
||||||
formError = '';
|
formError = '';
|
||||||
formSuccess = '';
|
|
||||||
|
|
||||||
const numericBatchSize = Number(batchSizeKg);
|
const numericBatchSize = Number(batchSizeKg);
|
||||||
if (!mixDate) {
|
if (!mixDate) {
|
||||||
@@ -184,7 +180,6 @@
|
|||||||
notes = '';
|
notes = '';
|
||||||
preview = null;
|
preview = null;
|
||||||
formError = '';
|
formError = '';
|
||||||
formSuccess = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printPreview() {
|
function printPreview() {
|
||||||
@@ -193,6 +188,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadSessionPdf(sessionId: number) {
|
||||||
|
const tid = toast.loading('Generating PDF…');
|
||||||
|
try {
|
||||||
|
const blob = await api.mixCalculatorSessionPdf(sessionId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `mix_calculator_${sessionId}.pdf`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.dismiss(tid);
|
||||||
|
} catch (error) {
|
||||||
|
toast.dismiss(tid);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to generate PDF.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreviewModal() {
|
||||||
|
if (!preview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreviewModal() {
|
||||||
|
previewModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') {
|
async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') {
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -237,11 +263,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if initialSession}
|
{#if initialSession}
|
||||||
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
|
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
|
||||||
|
<button class="secondary-button" type="button" onclick={() => downloadSessionPdf(initialSession.id)}>Download PDF</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="workspace-grid">
|
<section class="editor-grid">
|
||||||
<article class="form-card">
|
<article class="form-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -259,9 +286,6 @@
|
|||||||
{#if formError}
|
{#if formError}
|
||||||
<p class="message error">{formError}</p>
|
<p class="message error">{formError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formSuccess}
|
|
||||||
<p class="message success">{formSuccess}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<label>
|
<label>
|
||||||
@@ -337,6 +361,10 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={openPreviewModal}>
|
||||||
|
<span>Preview</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}>
|
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}>
|
||||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
||||||
<span>Print</span>
|
<span>Print</span>
|
||||||
@@ -351,202 +379,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="result-card">
|
<MixCalculatorResultsPanel
|
||||||
<div class="section-header">
|
preview={preview}
|
||||||
<div>
|
sessionNumber={initialSession?.session_number ?? null}
|
||||||
<h3>Calculated Output</h3>
|
/>
|
||||||
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
|
|
||||||
</div>
|
|
||||||
{#if initialSession}
|
|
||||||
<div class="session-chip">
|
|
||||||
<span>Session</span>
|
|
||||||
<strong>{initialSession.session_number}</strong>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if preview}
|
|
||||||
<div class="metric-row">
|
|
||||||
<article class="metric-card">
|
|
||||||
<span>Total kg</span>
|
|
||||||
<strong>{formatNumber(preview.total_kg, 2)}</strong>
|
|
||||||
<p>Scaled batch size</p>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span>Total bags</span>
|
|
||||||
<strong>{formatNumber(preview.total_bags, 2)}</strong>
|
|
||||||
<p>{preview.product_unit_of_measure}</p>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span>Prepared by</span>
|
|
||||||
<strong>{preview.prepared_by_name}</strong>
|
|
||||||
<p>{formatDate(preview.mix_date)}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if preview.warnings.length}
|
|
||||||
<div class="warning-stack">
|
|
||||||
{#each preview.warnings as warning}
|
|
||||||
<p>{warning}</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="summary-grid">
|
|
||||||
<div>
|
|
||||||
<span>Client</span>
|
|
||||||
<strong>{preview.client_name}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Product</span>
|
|
||||||
<strong>{preview.product_name}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Mix source</span>
|
|
||||||
<strong>{preview.mix_name}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Unit size</span>
|
|
||||||
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Raw material</th>
|
|
||||||
<th>Mix %</th>
|
|
||||||
<th>Required kg</th>
|
|
||||||
<th>Unit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each preview.lines as line}
|
|
||||||
<tr>
|
|
||||||
<td data-label="Raw material">
|
|
||||||
<strong>{line.raw_material_name}</strong>
|
|
||||||
</td>
|
|
||||||
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
|
|
||||||
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
|
|
||||||
<td data-label="Unit">{line.unit}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-shimmer-metrics">
|
|
||||||
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
|
||||||
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
|
||||||
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="empty-state-copy">
|
|
||||||
<div class="empty-icon" aria-hidden="true">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</div>
|
|
||||||
<strong>No calculation yet</strong>
|
|
||||||
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
|
|
||||||
</div>
|
|
||||||
<div class="empty-shimmer-rows">
|
|
||||||
{#each [1,2,3,4,5] as _}
|
|
||||||
<div class="shimmer-row">
|
|
||||||
<div class="shimmer-line wide"></div>
|
|
||||||
<div class="shimmer-line medium"></div>
|
|
||||||
<div class="shimmer-line medium"></div>
|
|
||||||
<div class="shimmer-line short"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if preview}
|
{#if preview}
|
||||||
|
{#if previewModalOpen}
|
||||||
|
<MixCalculatorPreviewModal
|
||||||
|
preview={preview}
|
||||||
|
sessionId={initialSession?.id ?? null}
|
||||||
|
onClose={closePreviewModal}
|
||||||
|
onPrint={printPreview}
|
||||||
|
onDownloadPdf={downloadSessionPdf}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="print-only" aria-hidden="true">
|
<section class="print-only" aria-hidden="true">
|
||||||
<article class="print-sheet">
|
<MixCalculatorPrintDocument session={preview} />
|
||||||
<header class="print-header">
|
|
||||||
<div>
|
|
||||||
<p class="print-eyebrow">Mix Calculator</p>
|
|
||||||
<h1>{preview.product_name}</h1>
|
|
||||||
<p class="print-subtitle">{preview.client_name} · {preview.mix_name}</p>
|
|
||||||
</div>
|
|
||||||
<div class="print-meta">
|
|
||||||
<div>
|
|
||||||
<span>Mix date</span>
|
|
||||||
<strong>{formatDate(preview.mix_date)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Prepared by</span>
|
|
||||||
<strong>{preview.prepared_by_name}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="print-summary">
|
|
||||||
<div>
|
|
||||||
<span>Batch size</span>
|
|
||||||
<strong>{formatNumber(preview.batch_size_kg, 2)}kg</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Total kilograms</span>
|
|
||||||
<strong>{formatNumber(preview.total_kg, 2)}kg</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Total bags</span>
|
|
||||||
<strong>{formatNumber(preview.total_bags, 2)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Unit size</span>
|
|
||||||
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if preview.notes}
|
|
||||||
<section class="print-notes">
|
|
||||||
<h2>Notes</h2>
|
|
||||||
<p>{preview.notes}</p>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if preview.warnings.length}
|
|
||||||
<section class="print-warnings">
|
|
||||||
<h2>Warnings</h2>
|
|
||||||
{#each preview.warnings as warning}
|
|
||||||
<p>{warning}</p>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="print-table">
|
|
||||||
<div class="print-table-header">
|
|
||||||
<h2>Required raw materials</h2>
|
|
||||||
<span>{preview.product_unit_of_measure} · {formatNumber(preview.product_unit_size_kg, 2)}kg per unit</span>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Raw material</th>
|
|
||||||
<th>Mix %</th>
|
|
||||||
<th>Required kg</th>
|
|
||||||
<th>Unit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each preview.lines as line}
|
|
||||||
<tr>
|
|
||||||
<td>{line.raw_material_name}</td>
|
|
||||||
<td>{formatNumber(line.mix_percentage, 2)}%</td>
|
|
||||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
|
||||||
<td>{line.unit}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -564,22 +415,10 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow-icon {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.95rem;
|
|
||||||
height: 0.95rem;
|
|
||||||
background-color: currentColor;
|
|
||||||
-webkit-mask: var(--button-icon-url) center / contain no-repeat;
|
|
||||||
mask: var(--button-icon-url) center / contain no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-actions,
|
.page-actions,
|
||||||
.workspace-grid {
|
.editor-grid {
|
||||||
margin-bottom: 1.2rem;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,30 +429,11 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro h2 {
|
|
||||||
margin: 0.3rem 0 0.45rem;
|
|
||||||
max-width: 16ch;
|
|
||||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro p:last-child,
|
|
||||||
.section-header p,
|
.section-header p,
|
||||||
.metric-card p,
|
.calculation-note span {
|
||||||
.summary-grid span,
|
|
||||||
.calculation-note span,
|
|
||||||
.empty-state span {
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions,
|
|
||||||
.action-row {
|
.action-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -621,15 +441,13 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-grid {
|
.editor-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card,
|
.form-card,
|
||||||
.result-card,
|
|
||||||
.metric-card,
|
|
||||||
.locked-card {
|
.locked-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1.3rem;
|
border-radius: 1.3rem;
|
||||||
@@ -638,7 +456,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-card,
|
.form-card,
|
||||||
.result-card,
|
|
||||||
.locked-card {
|
.locked-card {
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -665,8 +482,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-pill,
|
.product-pill {
|
||||||
.session-chip {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.14rem;
|
gap: 0.14rem;
|
||||||
padding: 0.72rem 0.82rem;
|
padding: 0.72rem 0.82rem;
|
||||||
@@ -675,8 +491,7 @@
|
|||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-pill span,
|
.product-pill span {
|
||||||
.session-chip span {
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -718,27 +533,15 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calculation-note,
|
|
||||||
.warning-stack,
|
|
||||||
.empty-state {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.92rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calculation-note {
|
.calculation-note {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.92rem;
|
||||||
|
border-radius: 1rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
background: #fff6e6;
|
|
||||||
color: #8b5b1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.75rem 0.85rem;
|
||||||
@@ -751,11 +554,6 @@
|
|||||||
color: #b2463f;
|
color: #b2463f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
|
||||||
background: var(--green-soft);
|
|
||||||
color: var(--green-deep);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -820,259 +618,20 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row,
|
|
||||||
.summary-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card span {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.84rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card strong {
|
|
||||||
display: block;
|
|
||||||
margin: 0.45rem 0 0.18rem;
|
|
||||||
font-size: 1.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid div {
|
|
||||||
padding: 0.88rem 0.92rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 30rem;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0.9rem 0.85rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
border-radius: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-shimmer-metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shimmer-metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.85rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 0.85rem;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
background: var(--panel);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-copy strong {
|
|
||||||
font-size: 0.98rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-copy span {
|
|
||||||
max-width: 26rem;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.28rem;
|
|
||||||
height: 2.2rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon span {
|
|
||||||
width: 0.38rem;
|
|
||||||
border-radius: 999px 999px 0 0;
|
|
||||||
background: var(--color-border);
|
|
||||||
animation: bar-pulse 1.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon span:nth-child(1) { height: 60%; animation-delay: 0s; }
|
|
||||||
.empty-icon span:nth-child(2) { height: 100%; animation-delay: 0.2s; }
|
|
||||||
.empty-icon span:nth-child(3) { height: 40%; animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
@keyframes bar-pulse {
|
|
||||||
0%, 100% { opacity: 0.35; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-shimmer-rows {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shimmer-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr 1fr 0.75fr;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.78rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shimmer-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shimmer-line {
|
|
||||||
height: 0.7rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--color-border) 25%,
|
|
||||||
color-mix(in srgb, var(--color-border) 40%, white) 50%,
|
|
||||||
var(--color-border) 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shimmer-line.short { width: 40%; }
|
|
||||||
.shimmer-line.medium { width: 65%; }
|
|
||||||
.shimmer-line.wide { width: 90%; }
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.workspace-grid {
|
.editor-grid {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.page-intro,
|
|
||||||
.section-header {
|
.section-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-grid,
|
.field-grid {
|
||||||
.summary-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
table,
|
|
||||||
thead,
|
|
||||||
tbody,
|
|
||||||
tr,
|
|
||||||
td {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody td {
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody td:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody td::before {
|
|
||||||
content: attr(data-label);
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.24rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-only {
|
.print-only {
|
||||||
@@ -1098,127 +657,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
padding: 1.4cm;
|
padding: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #1a2421;
|
color: #1a2421;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-sheet {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid #cbd6cf;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-header h1 {
|
|
||||||
margin: 0.25rem 0 0.3rem;
|
|
||||||
font-size: 1.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-eyebrow {
|
|
||||||
color: #5f6f67;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-subtitle {
|
|
||||||
color: #5f6f67;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
min-width: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-meta div,
|
|
||||||
.print-summary div {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-meta span,
|
|
||||||
.print-summary span,
|
|
||||||
.print-table-header span {
|
|
||||||
color: #5f6f67;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 0.85rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid #cbd6cf;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-notes,
|
|
||||||
.print-warnings {
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
padding: 0.75rem 0.9rem;
|
|
||||||
border: 1px solid #cbd6cf;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-warnings {
|
|
||||||
border-color: #d8a76b;
|
|
||||||
background: #fff6e6;
|
|
||||||
color: #8b5b1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-notes h2,
|
|
||||||
.print-warnings h2,
|
|
||||||
.print-table-header h2 {
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-table {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-table-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-table table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-table th,
|
|
||||||
.print-table td {
|
|
||||||
padding: 0.55rem 0.5rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #cbd6cf;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-table th {
|
|
||||||
color: #5f6f67;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@page {
|
|
||||||
margin: 1cm;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
||||||
|
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
preview,
|
||||||
|
sessionId = null,
|
||||||
|
onClose,
|
||||||
|
onPrint,
|
||||||
|
onDownloadPdf
|
||||||
|
}: {
|
||||||
|
preview: MixCalculatorPreview | MixCalculatorSession;
|
||||||
|
sessionId?: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onPrint: () => void;
|
||||||
|
onDownloadPdf: (sessionId: number) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="preview-modal-backdrop" role="presentation" onclick={onClose}>
|
||||||
|
<div
|
||||||
|
class="preview-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Print preview"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="preview-modal-toolbar">
|
||||||
|
<div>
|
||||||
|
<p class="preview-modal-kicker">Print Preview</p>
|
||||||
|
<h3>{preview.product_name}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="preview-modal-actions">
|
||||||
|
<button class="secondary-button" type="button" onclick={onClose}>Close</button>
|
||||||
|
{#if sessionId}
|
||||||
|
<a class="secondary-button" href={`/mix-calculator/${sessionId}/print`}>Open page</a>
|
||||||
|
<button class="secondary-button" type="button" onclick={() => onDownloadPdf(sessionId)}>Download PDF</button>
|
||||||
|
{/if}
|
||||||
|
<button class="primary-button" type="button" onclick={onPrint}>Print / Save PDF</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-sheet-frame">
|
||||||
|
<div class="preview-sheet-scroll">
|
||||||
|
<MixCalculatorPrintDocument session={preview} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 70;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(17, 24, 20, 0.52);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(248, 250, 248, 0.96), rgba(240, 246, 242, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal-kicker {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal-toolbar h3 {
|
||||||
|
margin-top: 0.24rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-sheet-frame {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: start center;
|
||||||
|
padding: 1.1rem;
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, #dfe8e2 0%, #eef3ef 45%, #d7e2db 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-sheet-scroll {
|
||||||
|
max-height: calc(100vh - 12rem);
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.78rem 0.96rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
border: none;
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
background: #fff;
|
||||||
|
color: #304038;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.preview-modal-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-sheet-frame {
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
preview,
|
||||||
|
sessionNumber = null
|
||||||
|
}: {
|
||||||
|
preview: MixCalculatorPreview | MixCalculatorSession | null;
|
||||||
|
sessionNumber?: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('en-NZ', {
|
||||||
|
dateStyle: 'medium'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | null | undefined, digits = 2) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toFixed(digits);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="result-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h3>Calculated Output</h3>
|
||||||
|
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
|
||||||
|
</div>
|
||||||
|
{#if sessionNumber}
|
||||||
|
<div class="session-chip">
|
||||||
|
<span>Session</span>
|
||||||
|
<strong>{sessionNumber}</strong>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<div class="metric-row">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Total kg</span>
|
||||||
|
<strong>{formatNumber(preview.total_kg, 2)}</strong>
|
||||||
|
<p>Scaled batch size</p>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Total bags</span>
|
||||||
|
<strong>{formatNumber(preview.total_bags, 2)}</strong>
|
||||||
|
<p>{preview.product_unit_of_measure}</p>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Prepared by</span>
|
||||||
|
<strong>{preview.prepared_by_name}</strong>
|
||||||
|
<p>{formatDate(preview.mix_date)}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if preview.warnings.length}
|
||||||
|
<div class="warning-stack">
|
||||||
|
{#each preview.warnings as warning}
|
||||||
|
<p>{warning}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div>
|
||||||
|
<span>Client</span>
|
||||||
|
<strong>{preview.client_name}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Product</span>
|
||||||
|
<strong>{preview.product_name}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Mix source</span>
|
||||||
|
<strong>{preview.mix_name}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Unit size</span>
|
||||||
|
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raw material</th>
|
||||||
|
<th>Mix %</th>
|
||||||
|
<th>Required kg</th>
|
||||||
|
<th>Unit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each preview.lines as line}
|
||||||
|
<tr>
|
||||||
|
<td data-label="Raw material">
|
||||||
|
<strong>{line.raw_material_name}</strong>
|
||||||
|
</td>
|
||||||
|
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
|
||||||
|
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
|
||||||
|
<td data-label="Unit">{line.unit}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-shimmer-metrics">
|
||||||
|
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
||||||
|
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
||||||
|
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state-copy">
|
||||||
|
<div class="empty-icon" aria-hidden="true">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<strong>No calculation yet</strong>
|
||||||
|
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
|
||||||
|
</div>
|
||||||
|
<div class="empty-shimmer-rows">
|
||||||
|
{#each [1,2,3,4,5] as _}
|
||||||
|
<div class="shimmer-row">
|
||||||
|
<div class="shimmer-line wide"></div>
|
||||||
|
<div class="shimmer-line medium"></div>
|
||||||
|
<div class="shimmer-line medium"></div>
|
||||||
|
<div class="shimmer-line short"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header p,
|
||||||
|
.metric-card p,
|
||||||
|
.summary-grid span,
|
||||||
|
.empty-state span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card,
|
||||||
|
.metric-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1.3rem;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-chip {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.14rem;
|
||||||
|
padding: 0.72rem 0.82rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.92rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-chip span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row,
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
display: block;
|
||||||
|
margin: 0.45rem 0 0.18rem;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.92rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #fff6e6;
|
||||||
|
color: #8b5b1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid div {
|
||||||
|
padding: 0.88rem 0.92rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 30rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.9rem 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-shimmer-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-copy strong {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-copy span {
|
||||||
|
max-width: 26rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.28rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon span {
|
||||||
|
width: 0.38rem;
|
||||||
|
border-radius: 999px 999px 0 0;
|
||||||
|
background: var(--color-border);
|
||||||
|
animation: bar-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon span:nth-child(1) { height: 60%; animation-delay: 0s; }
|
||||||
|
.empty-icon span:nth-child(2) { height: 100%; animation-delay: 0.2s; }
|
||||||
|
.empty-icon span:nth-child(3) { height: 40%; animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes bar-pulse {
|
||||||
|
0%, 100% { opacity: 0.35; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-shimmer-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 0.75fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.78rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-line {
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-border) 25%,
|
||||||
|
color-mix(in srgb, var(--color-border) 40%, white) 50%,
|
||||||
|
var(--color-border) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-line.short { width: 40%; }
|
||||||
|
.shimmer-line.medium { width: 65%; }
|
||||||
|
.shimmer-line.wide { width: 90%; }
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.metric-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tr,
|
||||||
|
td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.24rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+30
-100
@@ -28,8 +28,6 @@
|
|||||||
let mixVersion = $state(getInitialMix()?.version ?? 1);
|
let mixVersion = $state(getInitialMix()?.version ?? 1);
|
||||||
let mixNotes = $state(getInitialMix()?.notes ?? '');
|
let mixNotes = $state(getInitialMix()?.notes ?? '');
|
||||||
let draftIngredients = $state<DraftIngredient[]>([]);
|
let draftIngredients = $state<DraftIngredient[]>([]);
|
||||||
let feedback = $state('');
|
|
||||||
let errorMessage = $state('');
|
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
|
|
||||||
function currency(value: number | null | undefined, digits = 2) {
|
function currency(value: number | null | undefined, digits = 2) {
|
||||||
@@ -86,8 +84,6 @@
|
|||||||
loadDraftFromMix(getInitialMix());
|
loadDraftFromMix(getInitialMix());
|
||||||
|
|
||||||
function resetDraft() {
|
function resetDraft() {
|
||||||
feedback = '';
|
|
||||||
errorMessage = '';
|
|
||||||
loadDraftFromMix(savedMix);
|
loadDraftFromMix(savedMix);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,14 +314,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if feedback}
|
|
||||||
<p class="feedback success">{feedback}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if errorMessage}
|
|
||||||
<p class="feedback error">{errorMessage}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="metric-row">
|
<section class="metric-row">
|
||||||
<article class="metric-card">
|
<article class="metric-card">
|
||||||
<span>Live Draft Kg</span>
|
<span>Live Draft Kg</span>
|
||||||
@@ -567,7 +555,6 @@
|
|||||||
|
|
||||||
.locked-card,
|
.locked-card,
|
||||||
.page-intro,
|
.page-intro,
|
||||||
.feedback,
|
|
||||||
.metric-card,
|
.metric-card,
|
||||||
.editor-card,
|
.editor-card,
|
||||||
.summary-card {
|
.summary-card {
|
||||||
@@ -579,7 +566,6 @@
|
|||||||
|
|
||||||
.locked-card,
|
.locked-card,
|
||||||
.page-intro,
|
.page-intro,
|
||||||
.feedback,
|
|
||||||
.metric-row,
|
.metric-row,
|
||||||
.editor-grid {
|
.editor-grid {
|
||||||
margin-bottom: 1.12rem;
|
margin-bottom: 1.12rem;
|
||||||
@@ -663,23 +649,6 @@
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feedback {
|
|
||||||
padding: 0.86rem 0.94rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback.success {
|
|
||||||
color: var(--green-deep);
|
|
||||||
border-color: #d8ecdf;
|
|
||||||
background: #f6fcf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback.error {
|
|
||||||
color: #a03737;
|
|
||||||
border-color: #f0d9d9;
|
|
||||||
background: #fff8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row,
|
.metric-row,
|
||||||
.editor-grid,
|
.editor-grid,
|
||||||
.meta-grid,
|
.meta-grid,
|
||||||
@@ -841,76 +810,54 @@
|
|||||||
.factor-list strong,
|
.factor-list strong,
|
||||||
.healthy-card strong {
|
.healthy-card strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.22rem;
|
margin-bottom: 0.28rem;
|
||||||
font-size: 0.94rem;
|
font-size: 0.96rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning-list article,
|
|
||||||
.healthy-card {
|
|
||||||
padding: 0.9rem 0.94rem;
|
|
||||||
border-radius: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list article {
|
.warning-list article {
|
||||||
border: 1px solid #f1e2c2;
|
padding: 0.84rem 0.9rem;
|
||||||
background: #fffaf2;
|
border: 1px solid #f0d8d8;
|
||||||
color: #8d5d21;
|
border-radius: 0.92rem;
|
||||||
font-weight: 500;
|
background: #fff7f7;
|
||||||
|
color: #9a4747;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthy-card {
|
.healthy-card {
|
||||||
border: 1px solid var(--line);
|
padding: 0.95rem 1rem;
|
||||||
background: var(--panel-soft);
|
border: 1px solid #d9ecdf;
|
||||||
|
border-radius: 0.96rem;
|
||||||
|
background: #f6fcf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1240px) {
|
@media (max-width: 980px) {
|
||||||
|
.metric-row,
|
||||||
.editor-grid {
|
.editor-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-stack {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 720px) {
|
||||||
.metric-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.page-intro,
|
.page-intro,
|
||||||
.section-heading,
|
.section-heading,
|
||||||
.intro-actions,
|
.intro-actions,
|
||||||
.editor-actions {
|
.editor-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
}
|
|
||||||
|
|
||||||
.intro-actions,
|
|
||||||
.editor-actions,
|
|
||||||
.primary-button,
|
|
||||||
.secondary-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-grid,
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-grid,
|
.sheet-table {
|
||||||
.sidebar-stack {
|
min-width: 0;
|
||||||
grid-template-columns: 1fr;
|
border-spacing: 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 880px) {
|
|
||||||
.sheet-table,
|
.sheet-table,
|
||||||
.sheet-table thead,
|
.sheet-table thead,
|
||||||
.sheet-table tbody,
|
.sheet-table tbody,
|
||||||
@@ -920,61 +867,44 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table {
|
|
||||||
min-width: 0;
|
|
||||||
border-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-table thead {
|
.sheet-table thead {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table tbody {
|
.sheet-table tbody {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table tbody tr {
|
.sheet-table tbody tr {
|
||||||
padding: 0.35rem;
|
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table tbody td {
|
.sheet-table tbody td {
|
||||||
padding: 0.78rem 0.8rem;
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 0;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table tbody td:first-child,
|
|
||||||
.sheet-table tbody td:last-child {
|
.sheet-table tbody td:last-child {
|
||||||
border: none;
|
border-bottom: none;
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-table tbody td + td {
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table tbody td::before {
|
.sheet-table tbody td::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.24rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table input,
|
|
||||||
.sheet-table select,
|
|
||||||
.icon-delete {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentType } from 'svelte';
|
||||||
|
|
||||||
|
export type AppNavSectionItem = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
icon?: ComponentType;
|
||||||
|
active?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
|
type?: 'button' | 'link';
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
label = '',
|
||||||
|
ariaLabel = 'Navigation section',
|
||||||
|
items
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
items: AppNavSectionItem[];
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if label}
|
||||||
|
<p class="nav-section-label">{label}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<nav class="nav-list" aria-label={ariaLabel}>
|
||||||
|
{#each items as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
{#if item.href && item.type !== 'button'}
|
||||||
|
<a class:active={item.active} href={item.href}>
|
||||||
|
{#if Icon}
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
{/if}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="nav-button" class:active={item.active} onclick={item.onSelect}>
|
||||||
|
{#if Icon}
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
{/if}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nav-section-label {
|
||||||
|
margin: 0.85rem 0.55rem 0.3rem;
|
||||||
|
color: var(--nav-section-label-color, var(--muted));
|
||||||
|
font-size: var(--nav-section-label-size, 0.7rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: var(--nav-section-label-spacing, 0.1em);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list a,
|
||||||
|
.nav-button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.6rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-item-color, #3a4a41);
|
||||||
|
font-size: var(--nav-item-size, 0.93rem);
|
||||||
|
font-weight: var(--nav-item-weight, 500);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list a:hover,
|
||||||
|
.nav-button:hover {
|
||||||
|
background: var(--nav-item-hover-bg, var(--panel-soft));
|
||||||
|
color: var(--nav-item-hover-color, #304038);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list a.active,
|
||||||
|
.nav-button.active {
|
||||||
|
background: var(--nav-item-active-bg, var(--color-brand));
|
||||||
|
color: var(--nav-item-active-color, #fff);
|
||||||
|
font-weight: var(--nav-item-active-weight, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list a.active::before,
|
||||||
|
.nav-button.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -0.85rem;
|
||||||
|
top: 0.45rem;
|
||||||
|
bottom: 0.45rem;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--nav-item-active-marker, var(--color-brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
color: var(--nav-icon-color, #6d7d74);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
transition: color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list a:hover .nav-icon,
|
||||||
|
.nav-button:hover .nav-icon {
|
||||||
|
color: var(--nav-icon-hover-color, #304038);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list a.active .nav-icon,
|
||||||
|
.nav-button.active .nav-icon {
|
||||||
|
color: var(--nav-icon-active-color, #fff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentType } from 'svelte';
|
||||||
|
import AppNavSection, { type AppNavSectionItem } from '$lib/components/navigation/AppNavSection.svelte';
|
||||||
|
|
||||||
|
export type AppSecondaryRailItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: ComponentType;
|
||||||
|
group?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
sectionLabel,
|
||||||
|
identityTitle,
|
||||||
|
identitySubtitle,
|
||||||
|
identityAvatarText = '',
|
||||||
|
identityIcon,
|
||||||
|
groups,
|
||||||
|
activeId,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
sectionLabel: string;
|
||||||
|
identityTitle: string;
|
||||||
|
identitySubtitle: string;
|
||||||
|
identityAvatarText?: string;
|
||||||
|
identityIcon?: ComponentType;
|
||||||
|
groups: { label?: string; items: AppSecondaryRailItem[] }[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function toSectionItems(items: AppSecondaryRailItem[]): AppNavSectionItem[] {
|
||||||
|
return items.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon,
|
||||||
|
active: item.id === activeId,
|
||||||
|
onSelect: () => onSelect(item.id),
|
||||||
|
type: 'button'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="secondary-rail" aria-label={`${sectionLabel} navigation`}>
|
||||||
|
<p class="rail-label">{sectionLabel}</p>
|
||||||
|
|
||||||
|
<div class="rail-identity">
|
||||||
|
<div class="rail-avatar" aria-hidden="true">
|
||||||
|
{#if identityIcon}
|
||||||
|
{@const IdentityIcon = identityIcon}
|
||||||
|
<IdentityIcon size={16} strokeWidth={1.75} />
|
||||||
|
{:else}
|
||||||
|
{identityAvatarText}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="rail-identity-text">
|
||||||
|
<p class="identity-name">{identityTitle}</p>
|
||||||
|
<p class="identity-role">{identitySubtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each groups as group}
|
||||||
|
<div class="rail-group">
|
||||||
|
<AppNavSection label={group.label ?? ''} ariaLabel={group.label ?? `${sectionLabel} section`} items={toSectionItems(group.items)} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.secondary-rail {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
height: 100%;
|
||||||
|
min-height: calc(100vh - 8.5rem);
|
||||||
|
padding: 0;
|
||||||
|
background: color-mix(in srgb, var(--panel-soft) 72%, white);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
overflow-y: auto;
|
||||||
|
--nav-section-label-color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
|
||||||
|
--nav-section-label-size: 0.66rem;
|
||||||
|
--nav-section-label-spacing: 0.14em;
|
||||||
|
--nav-item-color: #66756d;
|
||||||
|
--nav-item-size: 0.88rem;
|
||||||
|
--nav-item-weight: 450;
|
||||||
|
--nav-item-hover-bg: color-mix(in srgb, var(--panel) 72%, transparent);
|
||||||
|
--nav-item-hover-color: #425148;
|
||||||
|
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 7%, transparent);
|
||||||
|
--nav-item-active-color: #22352d;
|
||||||
|
--nav-item-active-weight: 560;
|
||||||
|
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 28%, transparent);
|
||||||
|
--nav-icon-color: #8a9790;
|
||||||
|
--nav-icon-hover-color: #607067;
|
||||||
|
--nav-icon-active-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-label {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1rem 0.15rem;
|
||||||
|
color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 1rem 1.15rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.15rem;
|
||||||
|
height: 2.15rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--panel) 80%, #edf2ee);
|
||||||
|
color: #6b786f;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-identity-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #526059;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-role {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #8a9790;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
padding: 0.1rem 0.7rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-group + .rail-group {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-group :global(.nav-section-label) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.secondary-rail {
|
||||||
|
position: static;
|
||||||
|
min-height: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-group {
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-group :global(.nav-section-label) {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-group :global(.nav-list) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-group :global(.nav-list a),
|
||||||
|
.rail-group :global(.nav-button) {
|
||||||
|
width: auto;
|
||||||
|
padding-right: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
rail,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
rail: () => unknown;
|
||||||
|
children: () => unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="secondary-rail-layout">
|
||||||
|
<aside class="secondary-rail-layout-nav">
|
||||||
|
{@render rail()}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="secondary-rail-layout-panel">
|
||||||
|
<div class="secondary-rail-layout-content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.secondary-rail-layout {
|
||||||
|
margin: calc(var(--content-padding, 0rem) * -1);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 15rem minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100% + (var(--content-padding, 0rem) * 2));
|
||||||
|
min-height: calc(100% + (var(--content-padding, 0rem) * 2));
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-rail-layout-nav,
|
||||||
|
.secondary-rail-layout-panel {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-rail-layout-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-rail-layout-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-rail-layout-content > :global(*) {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.secondary-rail-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex: none;
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-rail-layout-nav {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-rail-layout-content {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { LogOut, Settings } from 'lucide-svelte';
|
||||||
|
|
||||||
|
import AppNavSection from '$lib/components/navigation/AppNavSection.svelte';
|
||||||
|
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
|
||||||
|
|
||||||
|
let {
|
||||||
|
currentPath,
|
||||||
|
primaryItems,
|
||||||
|
workingDocumentItems,
|
||||||
|
footerItems,
|
||||||
|
appVersion,
|
||||||
|
releaseStage,
|
||||||
|
currentYear,
|
||||||
|
onOpenSettings,
|
||||||
|
onSignOut
|
||||||
|
}: {
|
||||||
|
currentPath: string;
|
||||||
|
primaryItems: NavItem[];
|
||||||
|
workingDocumentItems: NavItem[];
|
||||||
|
footerItems: FooterLink[];
|
||||||
|
appVersion: string;
|
||||||
|
releaseStage: string;
|
||||||
|
currentYear: number;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onSignOut: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand-row">
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-body">
|
||||||
|
<AppNavSection
|
||||||
|
label="Modules"
|
||||||
|
ariaLabel="Client navigation"
|
||||||
|
items={primaryItems.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
icon: item.icon,
|
||||||
|
active: matchesRoute(item.href, currentPath)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if workingDocumentItems.length}
|
||||||
|
<AppNavSection
|
||||||
|
label="Working Docs"
|
||||||
|
ariaLabel="Working document pages"
|
||||||
|
items={workingDocumentItems.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
icon: item.icon,
|
||||||
|
active: matchesRoute(item.href, currentPath)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if footerItems.length}
|
||||||
|
<AppNavSection
|
||||||
|
label="More"
|
||||||
|
ariaLabel="Workspace shortcuts"
|
||||||
|
items={footerItems.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
icon: item.icon
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sidebar-meta">
|
||||||
|
<AppNavSection
|
||||||
|
ariaLabel="Account actions"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
icon: Settings,
|
||||||
|
active: currentPath.startsWith('/settings'),
|
||||||
|
onSelect: onOpenSettings,
|
||||||
|
type: 'button'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sign out',
|
||||||
|
icon: LogOut,
|
||||||
|
onSelect: onSignOut,
|
||||||
|
type: 'button'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="sidebar-meta-foot">
|
||||||
|
<div class="sidebar-meta-top">
|
||||||
|
<span class="version-pill">
|
||||||
|
<span class="meta-label">Build</span>
|
||||||
|
<span>{appVersion}</span>
|
||||||
|
</span>
|
||||||
|
<span class="release-pill">{releaseStage}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-meta-bottom">
|
||||||
|
<small>© {currentYear} Hunter Premium Produce</small>
|
||||||
|
<div class="powered-by">
|
||||||
|
<span>Powered by</span>
|
||||||
|
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
||||||
|
<strong>Lean 101</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 1.1rem 0.85rem 0.85rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-body {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.68rem;
|
||||||
|
padding: 0.2rem 0.35rem 0.95rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
width: min(100%, 15.5rem);
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-meta {
|
||||||
|
margin-top: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-meta-foot {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.8rem 0.55rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-meta-top,
|
||||||
|
.sidebar-meta-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-meta-foot small {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powered-by {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powered-by span {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powered-by strong {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5e6c64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lean101-logo {
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.24rem 0.56rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: #5e6c64;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.2rem 0.52rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-brand) 8%, white);
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Settings } from 'lucide-svelte';
|
||||||
|
|
||||||
|
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
|
||||||
|
import type { AppSession } from '$lib/session';
|
||||||
|
import type { Crumb } from '$lib/navigation/client-navigation';
|
||||||
|
|
||||||
|
let {
|
||||||
|
breadcrumbs,
|
||||||
|
title,
|
||||||
|
sessionHydrated,
|
||||||
|
session,
|
||||||
|
userInitials,
|
||||||
|
userMenuOpen,
|
||||||
|
onOpenPalette,
|
||||||
|
onToggleUserMenu,
|
||||||
|
onOpenSettings,
|
||||||
|
onSignOut
|
||||||
|
}: {
|
||||||
|
breadcrumbs: Crumb[];
|
||||||
|
title: string;
|
||||||
|
sessionHydrated: boolean;
|
||||||
|
session: AppSession | null;
|
||||||
|
userInitials: string;
|
||||||
|
userMenuOpen: boolean;
|
||||||
|
onOpenPalette: () => void;
|
||||||
|
onToggleUserMenu: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onSignOut: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-start">
|
||||||
|
<div class="topbar-copy">
|
||||||
|
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||||
|
{#each breadcrumbs as crumb, index}
|
||||||
|
{#if index > 0}<span class="breadcrumb-sep" aria-hidden="true">/</span>{/if}
|
||||||
|
{#if crumb.href && index < breadcrumbs.length - 1}
|
||||||
|
<a href={crumb.href}>{crumb.label}</a>
|
||||||
|
{:else}
|
||||||
|
<span aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>{crumb.label}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-middle">
|
||||||
|
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="menu-wrap user-menu-wrap">
|
||||||
|
<button aria-expanded={userMenuOpen} class="user-trigger" type="button" onclick={onToggleUserMenu}>
|
||||||
|
<span class="user-avatar-wrap">
|
||||||
|
<span class="user-avatar">{session ? userInitials : '?'}</span>
|
||||||
|
<span class={`user-status-dot ${session ? 'live' : 'idle'}`}></span>
|
||||||
|
</span>
|
||||||
|
<span class="user-trigger-copy">
|
||||||
|
<span class="workspace-label">{sessionHydrated ? (session ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
|
||||||
|
<strong>{sessionHydrated ? (session ? session.name || 'Client account' : 'Sign in required') : 'Restoring workspace access'}</strong>
|
||||||
|
</span>
|
||||||
|
<span class:open={userMenuOpen} class="chevron"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if userMenuOpen}
|
||||||
|
<div class="menu-panel user-menu-panel">
|
||||||
|
<div class="user-menu-summary">
|
||||||
|
<span class="user-menu-avatar">{session ? userInitials : '?'}</span>
|
||||||
|
<div class="user-menu-summary-text">
|
||||||
|
<strong>
|
||||||
|
{sessionHydrated
|
||||||
|
? session
|
||||||
|
? session.name || 'Client account'
|
||||||
|
: 'Client session inactive'
|
||||||
|
: 'Checking saved client session'}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
{sessionHydrated
|
||||||
|
? session
|
||||||
|
? session.email
|
||||||
|
: 'Return to the dashboard page to sign in.'
|
||||||
|
: 'Waiting for the browser session check to complete.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
|
||||||
|
<Settings size={15} strokeWidth={1.75} />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
{#if session}
|
||||||
|
<button type="button" onclick={onSignOut}>Log out</button>
|
||||||
|
{:else if !sessionHydrated}
|
||||||
|
<button type="button" disabled>Checking session...</button>
|
||||||
|
{:else}
|
||||||
|
<a href="/">Go to sign-in</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.72rem 1.2rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-start {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-copy h1 {
|
||||||
|
margin: 0.12rem 0 0;
|
||||||
|
font-size: 1.34rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.32rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a {
|
||||||
|
color: var(--muted);
|
||||||
|
transition: color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a:hover {
|
||||||
|
color: var(--green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs span[aria-current='page'] {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-sep {
|
||||||
|
color: #b9c5be;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-middle {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.topbar-search) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
background: color-mix(in srgb, var(--panel-soft) 68%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.68rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger {
|
||||||
|
min-width: 14rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.72rem;
|
||||||
|
padding: 0.56rem 0.76rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.96rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: #304038;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green-deep);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1.5px solid var(--panel-soft);
|
||||||
|
background: #b4c0ba;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status-dot.live {
|
||||||
|
background: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status-dot.idle {
|
||||||
|
background: #c08b3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green-deep);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.72rem 0.78rem;
|
||||||
|
border-radius: 0.82rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-summary-text {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-summary-text strong {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-summary-text span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger-copy strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-panel {
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-settings-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
width: 0.54rem;
|
||||||
|
height: 0.54rem;
|
||||||
|
border-right: 2px solid #7a8c82;
|
||||||
|
border-bottom: 2px solid #7a8c82;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
transition: transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.open {
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.45rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 13rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.96rem;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-panel a,
|
||||||
|
.menu-panel button {
|
||||||
|
padding: 0.72rem 0.78rem;
|
||||||
|
border-radius: 0.78rem;
|
||||||
|
color: #304038;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-panel a:hover,
|
||||||
|
.menu-panel button:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.topbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'start actions'
|
||||||
|
'middle middle';
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-start {
|
||||||
|
grid-area: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-middle {
|
||||||
|
grid-area: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
grid-area: actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.topbar {
|
||||||
|
padding: 0.72rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
label = 'Search the workspace',
|
||||||
|
placeholder = 'Search products, mixes, sessions, and pages...',
|
||||||
|
className = '',
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class={`search-box ${className}`.trim()} type="button" aria-label={label} onclick={onClick}>
|
||||||
|
<span class="search-icon"></span>
|
||||||
|
<span class="search-placeholder">{placeholder}</span>
|
||||||
|
<kbd>/</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.search-box {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.64rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.72rem 0.82rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.82rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-brand) 22%, var(--line));
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-placeholder {
|
||||||
|
color: #93a098;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.82rem;
|
||||||
|
height: 0.82rem;
|
||||||
|
border: 2px solid #98a59d;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -0.28rem;
|
||||||
|
bottom: -0.18rem;
|
||||||
|
width: 0.42rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #98a59d;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1rem 0.42rem;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 0.42rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
Calculator,
|
||||||
|
ClipboardList,
|
||||||
|
DollarSign,
|
||||||
|
FlaskConical,
|
||||||
|
LayoutDashboard,
|
||||||
|
ShieldCheck,
|
||||||
|
TrendingUp,
|
||||||
|
Wheat,
|
||||||
|
Workflow
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import type { ComponentType } from 'svelte';
|
||||||
|
|
||||||
|
import { featureFlags } from '$lib/features';
|
||||||
|
|
||||||
|
export type SearchItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
|
icon: ComponentType;
|
||||||
|
moduleKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FooterLink = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
|
icon: ComponentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Crumb = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardItem: NavItem = {
|
||||||
|
href: '/',
|
||||||
|
label: 'Dashboard',
|
||||||
|
shortLabel: 'DB',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
moduleKey: 'dashboard'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mixCalculatorItem: NavItem = {
|
||||||
|
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
|
||||||
|
label: 'Mix Calculator',
|
||||||
|
shortLabel: 'MC',
|
||||||
|
icon: Calculator,
|
||||||
|
moduleKey: 'mix_calculator'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reportingItem: NavItem = {
|
||||||
|
href: '/reporting',
|
||||||
|
label: 'Reporting',
|
||||||
|
shortLabel: 'RP',
|
||||||
|
icon: TrendingUp,
|
||||||
|
moduleKey: 'products'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const workingDocumentItems: NavItem[] = [
|
||||||
|
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
|
||||||
|
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
|
||||||
|
{ href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' },
|
||||||
|
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', icon: Workflow, moduleKey: 'scenarios' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const accessControlItem: NavItem = {
|
||||||
|
href: '/client-access',
|
||||||
|
label: 'Client Access',
|
||||||
|
shortLabel: 'AC',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
moduleKey: 'client_access'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clientNavigationItems: NavItem[] = [
|
||||||
|
dashboardItem,
|
||||||
|
mixCalculatorItem,
|
||||||
|
...workingDocumentItems,
|
||||||
|
accessControlItem
|
||||||
|
];
|
||||||
|
|
||||||
|
export const footerLinks: FooterLink[] = [
|
||||||
|
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
|
||||||
|
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const baseSearchItems: SearchItem[] = [
|
||||||
|
{
|
||||||
|
href: '/',
|
||||||
|
label: 'Open Dashboard',
|
||||||
|
description: 'Jump to the Hunter Premium Produce workspace summary.',
|
||||||
|
keywords: 'hunter premium produce overview dashboard workspace home'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/raw-materials',
|
||||||
|
label: 'Open Raw Materials',
|
||||||
|
description: 'Review live input costs that feed the pricing model.',
|
||||||
|
keywords: 'raw materials pricing inputs costs supplier'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/mixes',
|
||||||
|
label: 'Open Mix Master',
|
||||||
|
description: 'Browse saved mixes and their costing outputs.',
|
||||||
|
keywords: 'mix master mixes recipes spreadsheet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/mixes/new',
|
||||||
|
label: 'Create New Mix',
|
||||||
|
description: 'Start a new costing worksheet for Hunter Premium Produce.',
|
||||||
|
keywords: 'new mix create worksheet hunter premium produce formula'
|
||||||
|
},
|
||||||
|
...(featureFlags.mixCalculatorSessionHistory
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
href: '/mix-calculator',
|
||||||
|
label: 'Open Mix Calculator',
|
||||||
|
description: 'Review saved production sessions and batch calculations.',
|
||||||
|
keywords: 'mix calculator production sessions batch bags client product'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
href: '/mix-calculator/new',
|
||||||
|
label: 'Create Mix Calculation',
|
||||||
|
description: 'Run a new client-specific mix calculation session.',
|
||||||
|
keywords: 'new mix calculator session client batch size product bags print'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/products',
|
||||||
|
label: 'Open Products',
|
||||||
|
description: 'Review delivered product pricing and margins.',
|
||||||
|
keywords: 'products pricing margins delivered outputs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/reporting',
|
||||||
|
label: 'Open Reporting',
|
||||||
|
description: 'View raw material costs, mix summaries, product pricing, and data quality reports.',
|
||||||
|
keywords: 'reporting reports raw materials mix cost product pricing data quality price review'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings',
|
||||||
|
label: 'Open Workspace Settings',
|
||||||
|
description: 'Review account details and workspace preferences.',
|
||||||
|
keywords: 'settings account preferences profile workspace'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/scenarios',
|
||||||
|
label: 'Open Scenarios',
|
||||||
|
description: 'Inspect planning scenarios and overrides.',
|
||||||
|
keywords: 'scenarios sandbox overrides compare planning'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function matchesRoute(href: string, pathname: string) {
|
||||||
|
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageTitle(pathname: string) {
|
||||||
|
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientBreadcrumbs(pathname: string): Crumb[] {
|
||||||
|
const root: Crumb = { label: 'Workspace', href: '/' };
|
||||||
|
|
||||||
|
if (pathname === '/') {
|
||||||
|
return [root, { label: 'Dashboard' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/mix-calculator')) {
|
||||||
|
const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }];
|
||||||
|
if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' });
|
||||||
|
else if (pathname.endsWith('/print')) trail.push({ label: 'Print' });
|
||||||
|
else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' });
|
||||||
|
return trail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/mixes')) {
|
||||||
|
const trail: Crumb[] = [root, { label: 'Mix Master', href: '/mixes' }];
|
||||||
|
if (pathname === '/mixes/new') trail.push({ label: 'New Mix' });
|
||||||
|
else if (pathname !== '/mixes') trail.push({ label: 'Detail' });
|
||||||
|
return trail;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionMap: Record<string, string> = {
|
||||||
|
'/raw-materials': 'Raw Materials',
|
||||||
|
'/products': 'Products',
|
||||||
|
'/scenarios': 'Scenarios',
|
||||||
|
'/client-access': 'Client Access',
|
||||||
|
'/reporting': 'Reporting',
|
||||||
|
'/settings': 'Settings'
|
||||||
|
};
|
||||||
|
const section = sectionMap[pathname];
|
||||||
|
if (section) return [root, { label: section }];
|
||||||
|
|
||||||
|
return [root, { label: pageTitle(pathname) }];
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
|
import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
|
||||||
import { featureFlags } from '$lib/features';
|
import { featureFlags } from '$lib/features';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.session}
|
{#if data.session}
|
||||||
<MixCalculatorWorkspace initialSession={data.session} options={data.options} />
|
<MixCalculatorEditor initialSession={data.session} options={data.options} />
|
||||||
{:else}
|
{:else}
|
||||||
<section class="locked-card">
|
<section class="locked-card">
|
||||||
<p class="eyebrow">Mix Calculator</p>
|
<p class="eyebrow">Mix Calculator</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
|
import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MixCalculatorWorkspace options={data.options} />
|
<MixCalculatorEditor options={data.options} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
|
import MixEditor from '$lib/components/mixes/MixEditor.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MixWorkspace rawMaterials={data.rawMaterials} initialMix={data.mix} />
|
<MixEditor rawMaterials={data.rawMaterials} initialMix={data.mix} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
|
import MixEditor from '$lib/components/mixes/MixEditor.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MixWorkspace rawMaterials={data.rawMaterials} />
|
<MixEditor rawMaterials={data.rawMaterials} />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||||
|
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||||
import { clientSession } from '$lib/session';
|
import { clientSession } from '$lib/session';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import { BarChart3, CirclePlus, Wheat } from 'lucide-svelte';
|
import { BarChart3, CirclePlus, Wheat } from 'lucide-svelte';
|
||||||
@@ -47,7 +49,10 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const railGroups = [...new Set(railItems.map((item) => item.group))];
|
const railGroups = [...new Set(railItems.map((item) => item.group))].map((group) => ({
|
||||||
|
label: group,
|
||||||
|
items: railItems.filter((item) => item.group === group)
|
||||||
|
}));
|
||||||
let activeView = $state<RawMaterialsView>('overview');
|
let activeView = $state<RawMaterialsView>('overview');
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
let overviewMixesPage = $state(1);
|
let overviewMixesPage = $state(1);
|
||||||
@@ -229,38 +234,18 @@
|
|||||||
<p class="feedback error">{errorMessage}</p>
|
<p class="feedback error">{errorMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="workspace-layout">
|
<AppSecondaryRailLayout>
|
||||||
<nav class="workspace-nav" aria-label="Raw materials navigation">
|
{#snippet rail()}
|
||||||
<p class="nav-section-label">Raw Materials</p>
|
<AppSecondaryRail
|
||||||
|
sectionLabel="Raw Materials"
|
||||||
<div class="nav-identity">
|
identityTitle={`${activeMaterials.length} active inputs`}
|
||||||
<div class="nav-avatar" aria-hidden="true">
|
identitySubtitle={`${data.rawMaterials.length} tracked materials`}
|
||||||
<Wheat size={16} strokeWidth={1.75} />
|
identityIcon={Wheat}
|
||||||
</div>
|
groups={railGroups}
|
||||||
<div class="nav-identity-text">
|
activeId={activeView}
|
||||||
<p class="identity-name">{activeMaterials.length} active inputs</p>
|
onSelect={(id) => (activeView = id as RawMaterialsView)}
|
||||||
<p class="identity-role">{data.rawMaterials.length} tracked materials</p>
|
/>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each railGroups as group}
|
|
||||||
<div class="nav-group">
|
|
||||||
<p class="nav-group-label">{group}</p>
|
|
||||||
{#each railItems.filter((item) => item.group === group) as item}
|
|
||||||
{@const Icon = item.icon}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
class:active={activeView === item.id}
|
|
||||||
onclick={() => (activeView = item.id)}
|
|
||||||
>
|
|
||||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="workspace-panel">
|
<div class="workspace-panel">
|
||||||
{#if activeRailItem}
|
{#if activeRailItem}
|
||||||
@@ -620,7 +605,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppSecondaryRailLayout>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -651,7 +636,7 @@
|
|||||||
|
|
||||||
.locked-card,
|
.locked-card,
|
||||||
.feedback,
|
.feedback,
|
||||||
.workspace-layout {
|
:global(.secondary-rail-layout) {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,82 +673,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 15rem minmax(0, 1fr);
|
|
||||||
align-items: stretch;
|
|
||||||
min-height: calc(100vh - 8.5rem);
|
|
||||||
max-height: calc(100vh - 8.5rem);
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 1.15rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
height: 100%;
|
|
||||||
min-height: calc(100vh - 8.5rem);
|
|
||||||
padding: 1.1rem 0.85rem 0.85rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-section-label {
|
|
||||||
margin: 0 0.55rem 0.3rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-identity {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0 0.25rem 0.9rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-avatar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--green-deep);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-identity-text {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-role {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback {
|
.feedback {
|
||||||
padding: 0.95rem 1rem;
|
padding: 0.95rem 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -784,96 +693,22 @@
|
|||||||
.metric-row,
|
.metric-row,
|
||||||
.top-grid,
|
.top-grid,
|
||||||
.material-grid,
|
.material-grid,
|
||||||
.impact-grid,
|
.impact-grid {
|
||||||
.nav-group {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group {
|
|
||||||
gap: 0.12rem;
|
|
||||||
padding-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group-label {
|
|
||||||
margin: 0.15rem 0.55rem 0.3rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.6rem 0.6rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
background: transparent;
|
|
||||||
color: #3a4a41;
|
|
||||||
font-size: 0.93rem;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 140ms ease, color 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
color: #6d7d74;
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
transition: color 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
color: #304038;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover .nav-icon {
|
|
||||||
color: #304038;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-icon {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -0.85rem;
|
|
||||||
top: 0.45rem;
|
|
||||||
bottom: 0.45rem;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-panel {
|
.workspace-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -913,10 +748,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row {
|
.metric-row {
|
||||||
@@ -1194,7 +1026,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.workspace-layout,
|
:global(.secondary-rail-layout),
|
||||||
.metric-row,
|
.metric-row,
|
||||||
.top-grid,
|
.top-grid,
|
||||||
.material-grid,
|
.material-grid,
|
||||||
@@ -1202,19 +1034,6 @@
|
|||||||
.stats-grid {
|
.stats-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.workspace-nav {
|
|
||||||
position: static;
|
|
||||||
min-height: auto;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-layout {
|
|
||||||
min-height: auto;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
@@ -1239,21 +1058,5 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group-label {
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||||
|
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -12,6 +14,8 @@
|
|||||||
import type { ComponentType } from 'svelte';
|
import type { ComponentType } from 'svelte';
|
||||||
|
|
||||||
type ReportId =
|
type ReportId =
|
||||||
|
| 'sales-target-report'
|
||||||
|
| 'finished-product-kanban'
|
||||||
| 'summary'
|
| 'summary'
|
||||||
| 'raw-material-costs'
|
| 'raw-material-costs'
|
||||||
| 'mix-cost-summary'
|
| 'mix-cost-summary'
|
||||||
@@ -28,6 +32,20 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reports: ReportItem[] = [
|
const reports: ReportItem[] = [
|
||||||
|
{
|
||||||
|
id: 'sales-target-report',
|
||||||
|
label: 'Sales Target Report',
|
||||||
|
description: 'Embedded Power BI sales target view for current sales tracking and review.',
|
||||||
|
icon: FileText,
|
||||||
|
group: 'Power BI',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'finished-product-kanban',
|
||||||
|
label: 'Finished Product - Kanban',
|
||||||
|
description: 'Embedded Power BI board for finished product review and kanban-style planning.',
|
||||||
|
icon: FileText,
|
||||||
|
group: 'Power BI',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'summary',
|
id: 'summary',
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
@@ -72,61 +90,40 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const groups = [...new Set(reports.map((r) => r.group))];
|
const SALES_TARGET_REPORT_URL =
|
||||||
|
'https://app.powerbi.com/view?r=eyJrIjoiZjc1NjljNmEtMmJkMi00ZDlmLThjN2MtNjgyMzcxZDUwMzIyIiwidCI6IjEwNjk4Y2EyLTVmMmUtNGUwYS04ZTQ2LWE5ZGI0Nzk3ZjQ3MCJ9';
|
||||||
|
|
||||||
let activeId = $state<ReportId>('summary');
|
const FINISHED_PRODUCT_KANBAN_URL =
|
||||||
|
'https://app.powerbi.com/view?r=eyJrIjoiOTBjYTQ2MjMtZjMwNi00MjAzLTgxNDYtMmEzM2QwNjhlNmFlIiwidCI6IjEwNjk4Y2EyLTVmMmUtNGUwYS04ZTQ2LWE5ZGI0Nzk3ZjQ3MCJ9';
|
||||||
|
|
||||||
|
const orderedGroups = ['Power BI', 'Overview', 'Costing', 'Quality'];
|
||||||
|
|
||||||
|
const railGroups = orderedGroups
|
||||||
|
.filter((group) => reports.some((report) => report.group === group))
|
||||||
|
.map((group) => ({
|
||||||
|
label: group,
|
||||||
|
items: reports.filter((report) => report.group === group)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let activeId = $state<ReportId>('sales-target-report');
|
||||||
|
|
||||||
const activeReport = $derived(reports.find((r) => r.id === activeId) ?? reports[0]);
|
const activeReport = $derived(reports.find((r) => r.id === activeId) ?? reports[0]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="reporting-layout">
|
<AppSecondaryRailLayout>
|
||||||
<nav class="report-nav" aria-label="Report navigation">
|
{#snippet rail()}
|
||||||
<p class="nav-section-label">Reporting</p>
|
<AppSecondaryRail
|
||||||
|
sectionLabel="Reporting"
|
||||||
<div class="nav-identity">
|
identityTitle="Workspace reports"
|
||||||
<div class="nav-avatar" aria-hidden="true">
|
identitySubtitle="Costing and quality views"
|
||||||
<TrendingUp size={16} strokeWidth={1.75} />
|
identityIcon={TrendingUp}
|
||||||
</div>
|
groups={railGroups}
|
||||||
<div class="nav-identity-text">
|
activeId={activeId}
|
||||||
<p class="identity-name">Workspace reports</p>
|
onSelect={(id) => (activeId = id as ReportId)}
|
||||||
<p class="identity-role">Costing and quality views</p>
|
/>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each groups as group}
|
|
||||||
<div class="nav-group">
|
|
||||||
<p class="nav-group-label">{group}</p>
|
|
||||||
{#each reports.filter((r) => r.group === group) as report}
|
|
||||||
{@const Icon = report.icon}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
class:active={activeId === report.id}
|
|
||||||
onclick={() => (activeId = report.id)}
|
|
||||||
>
|
|
||||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
|
||||||
<span>{report.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="report-panel">
|
<div class="report-panel">
|
||||||
{#if activeReport}
|
|
||||||
{@const PanelIcon = activeReport.icon}
|
|
||||||
<header class="panel-header">
|
|
||||||
<div class="panel-header-icon" aria-hidden="true">
|
|
||||||
<PanelIcon size={16} strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="panel-eyebrow">{activeReport.group}</p>
|
|
||||||
<h2>{activeReport.label}</h2>
|
|
||||||
<p class="panel-description">{activeReport.description}</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
{#if activeId === 'summary'}
|
{#if activeId === 'summary'}
|
||||||
<div class="report-placeholder">
|
<div class="report-placeholder">
|
||||||
@@ -244,227 +241,127 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else if activeId === 'sales-target-report'}
|
||||||
|
<section class="powerbi-embed">
|
||||||
|
<header class="embed-header">
|
||||||
|
<div>
|
||||||
|
<h2>Sales Target Report</h2>
|
||||||
|
<p>Live embedded Power BI view for sales target tracking and review.</p>
|
||||||
|
</div>
|
||||||
|
<a class="powerbi-link" href={SALES_TARGET_REPORT_URL} target="_blank" rel="noreferrer">
|
||||||
|
Open in Power BI
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="powerbi-frame-shell">
|
||||||
|
<iframe
|
||||||
|
title="Sales Target Report"
|
||||||
|
src={SALES_TARGET_REPORT_URL}
|
||||||
|
class="powerbi-frame"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{:else if activeId === 'finished-product-kanban'}
|
||||||
|
<section class="powerbi-embed">
|
||||||
|
<header class="embed-header">
|
||||||
|
<div>
|
||||||
|
<h2>Finished Product - Kanban</h2>
|
||||||
|
<p>Live embedded Power BI view for finished product review and kanban-style planning.</p>
|
||||||
|
</div>
|
||||||
|
<a class="powerbi-link" href={FINISHED_PRODUCT_KANBAN_URL} target="_blank" rel="noreferrer">
|
||||||
|
Open in Power BI
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="powerbi-frame-shell">
|
||||||
|
<iframe
|
||||||
|
title="Finished Product - Kanban"
|
||||||
|
src={FINISHED_PRODUCT_KANBAN_URL}
|
||||||
|
class="powerbi-frame"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppSecondaryRailLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.reporting-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 15rem minmax(0, 1fr);
|
|
||||||
align-items: stretch;
|
|
||||||
min-height: calc(100vh - 8.5rem);
|
|
||||||
max-height: calc(100vh - 8.5rem);
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 1.15rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
height: 100%;
|
|
||||||
min-height: calc(100vh - 8.5rem);
|
|
||||||
padding: 1.1rem 0.85rem 0.85rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-section-label {
|
|
||||||
margin: 0 0.55rem 0.3rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-identity {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0 0.25rem 0.9rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-avatar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--green-deep);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-identity-text {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-role {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.12rem;
|
|
||||||
padding-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group + .nav-group {
|
|
||||||
padding-top: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group-label {
|
|
||||||
margin: 0.15rem 0.55rem 0.3rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.6rem 0.6rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
background: transparent;
|
|
||||||
color: #3a4a41;
|
|
||||||
font-size: 0.93rem;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 140ms ease, color 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
color: #6d7d74;
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
transition: color 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
color: #304038;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover .nav-icon {
|
|
||||||
color: #304038;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-icon {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -0.85rem;
|
|
||||||
top: 0.45rem;
|
|
||||||
bottom: 0.45rem;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-panel {
|
.report-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 2.4rem;
|
|
||||||
height: 2.4rem;
|
|
||||||
border-radius: 0.72rem;
|
|
||||||
background: var(--color-brand-tint);
|
|
||||||
color: var(--color-brand);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-brand) 15%, transparent);
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-eyebrow {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header h2 {
|
|
||||||
margin: 0.2rem 0 0.3rem;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
flex: 1;
|
padding: 1.25rem 1.35rem 1.35rem;
|
||||||
min-height: 0;
|
}
|
||||||
padding: 1.5rem;
|
|
||||||
overflow-y: auto;
|
.powerbi-embed {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-height: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0 0 0.35rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-header p {
|
||||||
|
margin: 0.18rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerbi-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.56rem 0.2rem;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-brand) 22%, transparent);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #405148;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerbi-frame-shell {
|
||||||
|
min-height: 36rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--line) 88%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f6f8f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerbi-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: min(78vh, 980px);
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Report placeholders ───────────────────────────────────────── */
|
/* ── Report placeholders ───────────────────────────────────────── */
|
||||||
@@ -598,43 +495,14 @@
|
|||||||
|
|
||||||
/* ── Responsive ────────────────────────────────────────────────── */
|
/* ── Responsive ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.reporting-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
min-height: auto;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-nav {
|
|
||||||
position: static;
|
|
||||||
min-height: auto;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group-label {
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.panel-header {
|
.embed-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerbi-link {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-header-row,
|
.preview-header-row,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||||
|
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||||
import { clientSession } from '$lib/session';
|
import { clientSession } from '$lib/session';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
|
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
|
||||||
@@ -81,37 +83,22 @@
|
|||||||
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
||||||
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const railGroups = [{ items: navItems }];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="settings-layout">
|
<AppSecondaryRailLayout>
|
||||||
<nav class="settings-nav" aria-label="Settings sections">
|
{#snippet rail()}
|
||||||
<p class="nav-section-label">Settings</p>
|
<AppSecondaryRail
|
||||||
|
sectionLabel="Settings"
|
||||||
<div class="nav-identity">
|
identityAvatarText={initials}
|
||||||
<div class="avatar" aria-hidden="true">{initials}</div>
|
identityTitle={$clientSession?.name ?? 'Unknown'}
|
||||||
<div class="identity-text">
|
identitySubtitle={$clientSession?.role_name ?? $clientSession?.role ?? 'User'}
|
||||||
<p class="identity-name">{$clientSession?.name ?? 'Unknown'}</p>
|
groups={railGroups}
|
||||||
<p class="identity-role">{$clientSession?.role_name ?? $clientSession?.role ?? 'User'}</p>
|
activeId={activeSection}
|
||||||
</div>
|
onSelect={(id) => (activeSection = id as Section)}
|
||||||
</div>
|
/>
|
||||||
|
{/snippet}
|
||||||
<ul>
|
|
||||||
{#each navItems as item}
|
|
||||||
{@const Icon = item.icon}
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-item"
|
|
||||||
class:active={activeSection === item.id}
|
|
||||||
onclick={() => (activeSection = item.id)}
|
|
||||||
>
|
|
||||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
{#if activeSection === 'profile'}
|
{#if activeSection === 'profile'}
|
||||||
@@ -180,175 +167,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppSecondaryRailLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 15rem minmax(0, 1fr);
|
|
||||||
align-items: stretch;
|
|
||||||
min-height: calc(100vh - 8.5rem);
|
|
||||||
max-height: calc(100vh - 8.5rem);
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 1.15rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
height: 100%;
|
|
||||||
min-height: calc(100vh - 8.5rem);
|
|
||||||
padding: 1.1rem 0.85rem 0.85rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-section-label {
|
|
||||||
margin: 0 0.55rem 0.3rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-identity {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0 0.25rem 0.9rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--green-deep);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-text {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-role {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav ul {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav li {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.6rem 0.6rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
background: transparent;
|
|
||||||
color: #3a4a41;
|
|
||||||
font-size: 0.93rem;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 140ms ease, color 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
color: #6d7d74;
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
transition: color 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
color: #304038;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover .nav-icon {
|
|
||||||
color: #304038;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-icon {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -0.85rem;
|
|
||||||
top: 0.45rem;
|
|
||||||
bottom: 0.45rem;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-section {
|
.panel-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -372,10 +203,7 @@
|
|||||||
|
|
||||||
.panel-form {
|
.panel-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1.5rem 1.75rem;
|
padding: 1.5rem 1.75rem;
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
@@ -464,32 +292,6 @@
|
|||||||
/* ── Responsive ─────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.settings-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
min-height: auto;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav {
|
|
||||||
position: static;
|
|
||||||
min-height: auto;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav ul {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
width: auto;
|
|
||||||
padding-right: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row {
|
.field-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user