Seed additional products, improve left rail, improve search box, add visuals to buttons, rename working documents to working docs and improve left rail nav
This commit is contained in:
+558
-59
@@ -1,5 +1,11 @@
|
|||||||
from datetime import date, datetime
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.db.session import Base, SessionLocal, engine
|
from app.db.session import Base, SessionLocal, engine
|
||||||
@@ -11,13 +17,543 @@ from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
|||||||
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
|
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_ID = "hunter-premium-produce"
|
||||||
|
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
|
||||||
|
WORKBOOK_SENTINEL_ITEM_ID = "404266"
|
||||||
|
WORKBOOK_PATH = Path(__file__).resolve().parents[2] / "Input Cost Spreadsheet(1).xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if normalized.lower() in {"#n/a", "#value!", "n/a", "na", "none"}:
|
||||||
|
return None
|
||||||
|
return normalized
|
||||||
|
return str(value).strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().replace(",", "")
|
||||||
|
if not normalized or normalized.lower() in {"#n/a", "#value!", "n/a", "na", "none"}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_quantity(value: float | int | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "0"
|
||||||
|
numeric = float(value)
|
||||||
|
if abs(numeric - round(numeric)) < 1e-9:
|
||||||
|
return str(int(round(numeric)))
|
||||||
|
return f"{numeric:.4f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(value: str | None, *, fallback: str) -> str:
|
||||||
|
base = _text(value) or fallback
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "_", base.lower()).strip("_")
|
||||||
|
return slug or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sale_type(value) -> str:
|
||||||
|
label = (_text(value) or "standard").lower()
|
||||||
|
if label == "per unit":
|
||||||
|
return "per_unit"
|
||||||
|
return re.sub(r"[^a-z0-9]+", "_", label)
|
||||||
|
|
||||||
|
|
||||||
|
def _sheet_own_bag_to_model(value) -> bool:
|
||||||
|
label = (_text(value) or "").lower()
|
||||||
|
return label == "no bag"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_raw_material_unit(unit_label, kg_per_unit: float | None) -> str:
|
||||||
|
label = (_text(unit_label) or "").lower()
|
||||||
|
if label in {"per ton", "per tonne", "ton", "tonne"}:
|
||||||
|
return "tonne"
|
||||||
|
if label == "kg":
|
||||||
|
return "kg"
|
||||||
|
if label == "per bag 20kg":
|
||||||
|
return "20kg bag"
|
||||||
|
if "20 kg" in label:
|
||||||
|
return "20kg bag"
|
||||||
|
if "kg" in label and kg_per_unit:
|
||||||
|
return f"{_format_quantity(kg_per_unit)}kg bag"
|
||||||
|
if kg_per_unit == 1000:
|
||||||
|
return "tonne"
|
||||||
|
return _text(unit_label) or "kg"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_base_unit_label(sale_type: str, std_unit: float, own_bag: bool) -> str:
|
||||||
|
if sale_type == "standard":
|
||||||
|
return f"{_format_quantity(std_unit)}kg no bag" if own_bag else f"{_format_quantity(std_unit)}kg bag"
|
||||||
|
if sale_type == "bulka":
|
||||||
|
return f"{_format_quantity(std_unit)}kg bulka"
|
||||||
|
if sale_type == "per_unit":
|
||||||
|
return f"{_format_quantity(std_unit)} unit"
|
||||||
|
return f"{_format_quantity(std_unit)}kg"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_margin(finished_cost: float, sell_price) -> float | None:
|
||||||
|
price = _number(sell_price)
|
||||||
|
if price is None or price <= 0 or finished_cost <= 0 or price <= finished_cost:
|
||||||
|
return None
|
||||||
|
margin = 1 - (finished_cost / price)
|
||||||
|
if margin <= 0 or margin >= 1:
|
||||||
|
return None
|
||||||
|
return round(margin, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking_cost: float) -> str | None:
|
||||||
|
if abs(grading_cost) < 1e-9 and abs(bagging_cost) < 1e-9 and abs(cracking_cost) < 1e-9:
|
||||||
|
return None
|
||||||
|
base = _slug(label, fallback="custom_process")
|
||||||
|
return f"{base}_g{int(round(grading_cost * 1000))}_b{int(round(bagging_cost * 1000))}_c{int(round(cracking_cost * 1000))}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_workbook():
|
||||||
|
if not WORKBOOK_PATH.exists():
|
||||||
|
raise FileNotFoundError(f"Workbook not found at {WORKBOOK_PATH}")
|
||||||
|
return load_workbook(WORKBOOK_PATH, data_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_raw_material_rows(workbook) -> list[dict]:
|
||||||
|
rows: list[dict] = []
|
||||||
|
worksheet = workbook["C- Raw Products Costs"]
|
||||||
|
for row in worksheet.iter_rows(min_row=3, values_only=True):
|
||||||
|
name = _text(row[0])
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
market_value = _number(row[1])
|
||||||
|
kg_per_unit = _number(row[3])
|
||||||
|
waste_percentage = _number(row[4]) or 0.0
|
||||||
|
cost_per_kg = _number(row[7])
|
||||||
|
|
||||||
|
if cost_per_kg is None and market_value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if kg_per_unit is None or kg_per_unit <= 0:
|
||||||
|
kg_per_unit = 1.0
|
||||||
|
|
||||||
|
if market_value is None and cost_per_kg is not None:
|
||||||
|
market_value = round(cost_per_kg * kg_per_unit, 4)
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"unit_of_measure": _normalize_raw_material_unit(row[2], kg_per_unit),
|
||||||
|
"kg_per_unit": kg_per_unit,
|
||||||
|
"market_value": round(market_value, 4) if market_value is not None else None,
|
||||||
|
"waste_percentage": waste_percentage,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mix_rows(workbook) -> dict[tuple[str, str], dict]:
|
||||||
|
worksheet = workbook["M - All"]
|
||||||
|
header_row = next(worksheet.iter_rows(min_row=1, max_row=1, values_only=True))
|
||||||
|
ingredient_names = [_text(value) for value in header_row[3:] if _text(value)]
|
||||||
|
best_rows: dict[tuple[str, str], dict] = {}
|
||||||
|
|
||||||
|
for row in worksheet.iter_rows(min_row=2, values_only=True):
|
||||||
|
client_name = _text(row[0])
|
||||||
|
mix_name = _text(row[1])
|
||||||
|
if not client_name or not mix_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ingredients = []
|
||||||
|
for ingredient_name, quantity in zip(ingredient_names, row[3 : 3 + len(ingredient_names)]):
|
||||||
|
numeric_quantity = _number(quantity)
|
||||||
|
if ingredient_name and numeric_quantity and numeric_quantity > 0:
|
||||||
|
ingredients.append({"raw_material_name": ingredient_name, "quantity_kg": numeric_quantity})
|
||||||
|
|
||||||
|
if not ingredients:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_kg = _number(row[2]) or round(sum(item["quantity_kg"] for item in ingredients), 4)
|
||||||
|
score = (len(ingredients), 1 if _number(row[2]) is not None else 0, total_kg)
|
||||||
|
key = (client_name, mix_name)
|
||||||
|
current = best_rows.get(key)
|
||||||
|
if current is None or score > current["score"]:
|
||||||
|
best_rows[key] = {
|
||||||
|
"client_name": client_name,
|
||||||
|
"name": mix_name,
|
||||||
|
"ingredients": ingredients,
|
||||||
|
"total_kg": total_kg,
|
||||||
|
"score": score,
|
||||||
|
}
|
||||||
|
|
||||||
|
return best_rows
|
||||||
|
|
||||||
|
|
||||||
|
def _read_product_rows(workbook) -> list[dict]:
|
||||||
|
worksheet = workbook["Product Cost - Price"]
|
||||||
|
raw_rows: list[dict] = []
|
||||||
|
unit_variants: dict[tuple[str, bool, float], Counter[tuple[float, float]]] = {}
|
||||||
|
|
||||||
|
for row in worksheet.iter_rows(min_row=5, values_only=True):
|
||||||
|
item_id = _text(row[1])
|
||||||
|
name = _text(row[2])
|
||||||
|
mix_name = _text(row[3])
|
||||||
|
if not item_id or not name or not mix_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sale_type = _normalize_sale_type(row[4])
|
||||||
|
own_bag = _sheet_own_bag_to_model(row[5])
|
||||||
|
std_unit = _number(row[6]) or 1.0
|
||||||
|
bag_cost = round(_number(row[15]) or 0.0, 4)
|
||||||
|
freight_cost = round(_number(row[16]) or 0.0, 4)
|
||||||
|
base_unit_key = (sale_type, own_bag, std_unit)
|
||||||
|
|
||||||
|
unit_variants.setdefault(base_unit_key, Counter())[(bag_cost, freight_cost)] += 1
|
||||||
|
raw_rows.append(
|
||||||
|
{
|
||||||
|
"client_name": _text(row[0]) or "General",
|
||||||
|
"item_id": item_id,
|
||||||
|
"name": name,
|
||||||
|
"mix_name": mix_name,
|
||||||
|
"sale_type": sale_type,
|
||||||
|
"own_bag": own_bag,
|
||||||
|
"std_unit": std_unit,
|
||||||
|
"items_per_pallet": int(round(_number(row[7]) or 1)),
|
||||||
|
"grading_cost": round(_number(row[12]) or 0.0, 4),
|
||||||
|
"bagging_cost": round(_number(row[13]) or 0.0, 4),
|
||||||
|
"cracking_cost": round(_number(row[14]) or 0.0, 4),
|
||||||
|
"bag_cost": bag_cost,
|
||||||
|
"freight_cost": freight_cost,
|
||||||
|
"finished_product_delivered": round(_number(row[17]) or 0.0, 4),
|
||||||
|
"distributor_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[19]),
|
||||||
|
"wholesale_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[20]),
|
||||||
|
"process_label": _text(row[8]),
|
||||||
|
"sheet_own_bag": _text(row[5]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
products: list[dict] = []
|
||||||
|
for row in raw_rows:
|
||||||
|
base_unit_key = (row["sale_type"], row["own_bag"], row["std_unit"])
|
||||||
|
unit_label = _build_base_unit_label(row["sale_type"], row["std_unit"], row["own_bag"])
|
||||||
|
variant_counts = unit_variants[base_unit_key]
|
||||||
|
if len(variant_counts) > 1:
|
||||||
|
current_variant = (row["bag_cost"], row["freight_cost"])
|
||||||
|
primary_variant = variant_counts.most_common(1)[0][0]
|
||||||
|
if current_variant != primary_variant:
|
||||||
|
if row["sheet_own_bag"] == "Yes":
|
||||||
|
unit_label = f"{unit_label} (Own Bag)"
|
||||||
|
elif row["client_name"] == "Peckish":
|
||||||
|
unit_label = f"{unit_label} (Peckish)"
|
||||||
|
elif row["client_name"] == "Uncategorized":
|
||||||
|
unit_label = f"{unit_label} (Bulk)"
|
||||||
|
else:
|
||||||
|
unit_label = f"{unit_label} ({row['client_name']})"
|
||||||
|
|
||||||
|
process_key = _build_process_key(
|
||||||
|
row["process_label"],
|
||||||
|
row["grading_cost"],
|
||||||
|
row["bagging_cost"],
|
||||||
|
row["cracking_cost"],
|
||||||
|
)
|
||||||
|
row["unit_of_measure"] = unit_label
|
||||||
|
row["bagging_process"] = process_key
|
||||||
|
products.append(row)
|
||||||
|
|
||||||
|
return products
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_raw_materials(db, rows: list[dict]) -> dict[str, RawMaterial]:
|
||||||
|
existing_map = {
|
||||||
|
material.name: material
|
||||||
|
for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == TENANT_ID)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
material = existing_map.get(row["name"])
|
||||||
|
if material is None:
|
||||||
|
material = RawMaterial(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
name=row["name"],
|
||||||
|
supplier="Workbook Import",
|
||||||
|
unit_of_measure=row["unit_of_measure"],
|
||||||
|
kg_per_unit=row["kg_per_unit"],
|
||||||
|
status="active",
|
||||||
|
notes="Seeded from Input Cost Spreadsheet(1).xlsx",
|
||||||
|
)
|
||||||
|
db.add(material)
|
||||||
|
db.flush()
|
||||||
|
existing_map[row["name"]] = material
|
||||||
|
else:
|
||||||
|
material.unit_of_measure = row["unit_of_measure"]
|
||||||
|
material.kg_per_unit = row["kg_per_unit"]
|
||||||
|
material.status = "active"
|
||||||
|
material.notes = "Seeded from Input Cost Spreadsheet(1).xlsx"
|
||||||
|
|
||||||
|
active_price = next((price for price in material.price_versions if price.status == "active"), None)
|
||||||
|
if row["market_value"] is not None and row["market_value"] > 0:
|
||||||
|
if active_price is None:
|
||||||
|
material.price_versions.append(
|
||||||
|
RawMaterialPriceVersion(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
market_value=row["market_value"],
|
||||||
|
waste_percentage=row["waste_percentage"],
|
||||||
|
effective_date=WORKBOOK_EFFECTIVE_DATE,
|
||||||
|
status="active",
|
||||||
|
notes="Seeded from Input Cost Spreadsheet(1).xlsx",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
active_price.market_value = row["market_value"]
|
||||||
|
active_price.waste_percentage = row["waste_percentage"]
|
||||||
|
active_price.effective_date = WORKBOOK_EFFECTIVE_DATE
|
||||||
|
active_price.status = "active"
|
||||||
|
active_price.notes = "Seeded from Input Cost Spreadsheet(1).xlsx"
|
||||||
|
elif active_price is not None and active_price.market_value <= 0:
|
||||||
|
active_price.status = "inactive"
|
||||||
|
active_price.notes = "Disabled during workbook import because market value was non-positive"
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
return existing_map
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_process_rules(db, products: list[dict]) -> None:
|
||||||
|
existing_rules = {
|
||||||
|
rule.process_name: rule
|
||||||
|
for rule in db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == TENANT_ID)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
process_name = product["bagging_process"]
|
||||||
|
if not process_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rule = existing_rules.get(process_name)
|
||||||
|
if rule is None:
|
||||||
|
rule = ProcessCostRule(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
process_name=process_name,
|
||||||
|
grading_cost=product["grading_cost"],
|
||||||
|
bagging_cost=product["bagging_cost"],
|
||||||
|
cracking_cost=product["cracking_cost"],
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
existing_rules[process_name] = rule
|
||||||
|
else:
|
||||||
|
rule.grading_cost = product["grading_cost"]
|
||||||
|
rule.bagging_cost = product["bagging_cost"]
|
||||||
|
rule.cracking_cost = product["cracking_cost"]
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_packaging_and_freight_rules(db, products: list[dict]) -> None:
|
||||||
|
packaging_rules = {
|
||||||
|
(rule.sale_type, rule.unit_of_measure, rule.own_bag): rule
|
||||||
|
for rule in db.scalars(select(PackagingCostRule).where(PackagingCostRule.tenant_id == TENANT_ID)).all()
|
||||||
|
}
|
||||||
|
freight_rules = {
|
||||||
|
(rule.sale_type, rule.unit_of_measure): rule
|
||||||
|
for rule in db.scalars(select(FreightCostRule).where(FreightCostRule.tenant_id == TENANT_ID)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
packaging_key = (product["sale_type"], product["unit_of_measure"], product["own_bag"])
|
||||||
|
packaging_rule = packaging_rules.get(packaging_key)
|
||||||
|
if packaging_rule is None:
|
||||||
|
packaging_rule = PackagingCostRule(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
sale_type=product["sale_type"],
|
||||||
|
unit_of_measure=product["unit_of_measure"],
|
||||||
|
own_bag=product["own_bag"],
|
||||||
|
bag_cost=product["bag_cost"],
|
||||||
|
)
|
||||||
|
db.add(packaging_rule)
|
||||||
|
packaging_rules[packaging_key] = packaging_rule
|
||||||
|
else:
|
||||||
|
packaging_rule.bag_cost = product["bag_cost"]
|
||||||
|
|
||||||
|
freight_key = (product["sale_type"], product["unit_of_measure"])
|
||||||
|
freight_rule = freight_rules.get(freight_key)
|
||||||
|
if freight_rule is None:
|
||||||
|
freight_rule = FreightCostRule(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
sale_type=product["sale_type"],
|
||||||
|
unit_of_measure=product["unit_of_measure"],
|
||||||
|
cost_per_unit=product["freight_cost"],
|
||||||
|
)
|
||||||
|
db.add(freight_rule)
|
||||||
|
freight_rules[freight_key] = freight_rule
|
||||||
|
else:
|
||||||
|
freight_rule.cost_per_unit = product["freight_cost"]
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_mix(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
client_name: str,
|
||||||
|
mix_name: str,
|
||||||
|
ingredients: list[dict],
|
||||||
|
raw_material_map: dict[str, RawMaterial],
|
||||||
|
mix_cache: dict[tuple[str, str], Mix],
|
||||||
|
) -> Mix:
|
||||||
|
key = (client_name, mix_name)
|
||||||
|
mix = mix_cache.get(key)
|
||||||
|
if mix is None:
|
||||||
|
mix = db.scalar(
|
||||||
|
select(Mix).where(
|
||||||
|
Mix.tenant_id == TENANT_ID,
|
||||||
|
Mix.client_name == client_name,
|
||||||
|
Mix.name == mix_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if mix is None:
|
||||||
|
mix = Mix(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
client_name=client_name,
|
||||||
|
name=mix_name,
|
||||||
|
status="active",
|
||||||
|
version=1,
|
||||||
|
notes="Seeded from Input Cost Spreadsheet(1).xlsx",
|
||||||
|
)
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
mix_cache[key] = mix
|
||||||
|
|
||||||
|
existing_ingredients = {
|
||||||
|
ingredient.raw_material_id: ingredient
|
||||||
|
for ingredient in db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all()
|
||||||
|
}
|
||||||
|
desired_ids = set()
|
||||||
|
for ingredient_row in ingredients:
|
||||||
|
raw_material = raw_material_map.get(ingredient_row["raw_material_name"])
|
||||||
|
if raw_material is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
desired_ids.add(raw_material.id)
|
||||||
|
ingredient = existing_ingredients.get(raw_material.id)
|
||||||
|
if ingredient is None:
|
||||||
|
db.add(
|
||||||
|
MixIngredient(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
mix_id=mix.id,
|
||||||
|
raw_material_id=raw_material.id,
|
||||||
|
quantity_kg=ingredient_row["quantity_kg"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ingredient.quantity_kg = ingredient_row["quantity_kg"]
|
||||||
|
|
||||||
|
for raw_material_id, ingredient in existing_ingredients.items():
|
||||||
|
if raw_material_id not in desired_ids:
|
||||||
|
db.delete(ingredient)
|
||||||
|
|
||||||
|
return mix
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_single_material_mix(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
client_name: str,
|
||||||
|
raw_material_name: str,
|
||||||
|
raw_material_map: dict[str, RawMaterial],
|
||||||
|
mix_cache: dict[tuple[str, str], Mix],
|
||||||
|
) -> Mix:
|
||||||
|
raw_material = raw_material_map[raw_material_name]
|
||||||
|
return _upsert_mix(
|
||||||
|
db,
|
||||||
|
client_name=client_name,
|
||||||
|
mix_name=raw_material_name,
|
||||||
|
ingredients=[
|
||||||
|
{
|
||||||
|
"raw_material_name": raw_material_name,
|
||||||
|
"quantity_kg": raw_material.kg_per_unit or 1.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
raw_material_map=raw_material_map,
|
||||||
|
mix_cache=mix_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str], Mix], raw_material_map: dict[str, RawMaterial]) -> None:
|
||||||
|
mix_cache = dict(mix_lookup)
|
||||||
|
mixes_by_name: dict[str, list[Mix]] = {}
|
||||||
|
for mix in mix_cache.values():
|
||||||
|
mixes_by_name.setdefault(mix.name, []).append(mix)
|
||||||
|
existing_products = {
|
||||||
|
product.item_id: product
|
||||||
|
for product in db.scalars(select(Product).where(Product.tenant_id == TENANT_ID)).all()
|
||||||
|
if product.item_id
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in products:
|
||||||
|
mix = mix_cache.get((row["client_name"], row["mix_name"]))
|
||||||
|
if mix is None:
|
||||||
|
named_mixes = mixes_by_name.get(row["mix_name"], [])
|
||||||
|
if len(named_mixes) == 1:
|
||||||
|
mix = named_mixes[0]
|
||||||
|
if mix is None and row["mix_name"] in raw_material_map:
|
||||||
|
mix = _ensure_single_material_mix(
|
||||||
|
db,
|
||||||
|
client_name=row["client_name"],
|
||||||
|
raw_material_name=row["mix_name"],
|
||||||
|
raw_material_map=raw_material_map,
|
||||||
|
mix_cache=mix_cache,
|
||||||
|
)
|
||||||
|
if mix is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
product = existing_products.get(row["item_id"])
|
||||||
|
if product is None:
|
||||||
|
product = Product(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
client_name=row["client_name"],
|
||||||
|
item_id=row["item_id"],
|
||||||
|
name=row["name"],
|
||||||
|
mix_id=mix.id,
|
||||||
|
sale_type=row["sale_type"],
|
||||||
|
own_bag=row["own_bag"],
|
||||||
|
unit_of_measure=row["unit_of_measure"],
|
||||||
|
items_per_pallet=row["items_per_pallet"],
|
||||||
|
bagging_process=row["bagging_process"],
|
||||||
|
distributor_margin=row["distributor_margin"],
|
||||||
|
wholesale_margin=row["wholesale_margin"],
|
||||||
|
notes="Seeded from Input Cost Spreadsheet(1).xlsx",
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
existing_products[row["item_id"]] = product
|
||||||
|
else:
|
||||||
|
product.client_name = row["client_name"]
|
||||||
|
product.name = row["name"]
|
||||||
|
product.mix_id = mix.id
|
||||||
|
product.sale_type = row["sale_type"]
|
||||||
|
product.own_bag = row["own_bag"]
|
||||||
|
product.unit_of_measure = row["unit_of_measure"]
|
||||||
|
product.items_per_pallet = row["items_per_pallet"]
|
||||||
|
product.bagging_process = row["bagging_process"]
|
||||||
|
product.distributor_margin = row["distributor_margin"]
|
||||||
|
product.wholesale_margin = row["wholesale_margin"]
|
||||||
|
product.notes = "Seeded from Input Cost Spreadsheet(1).xlsx"
|
||||||
|
|
||||||
|
|
||||||
def seed_client_access(db):
|
def seed_client_access(db):
|
||||||
existing = db.scalar(select(ClientAccount.id))
|
existing = db.scalar(select(ClientAccount.id))
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
specialty = ClientAccount(
|
specialty = ClientAccount(
|
||||||
tenant_id="hunter-premium-produce",
|
tenant_id=TENANT_ID,
|
||||||
name="Hunter Premium Produce",
|
name="Hunter Premium Produce",
|
||||||
client_code="HPP",
|
client_code="HPP",
|
||||||
status="active",
|
status="active",
|
||||||
@@ -72,7 +608,7 @@ def seed_client_access(db):
|
|||||||
)
|
)
|
||||||
|
|
||||||
enabled_feature_map = {
|
enabled_feature_map = {
|
||||||
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"},
|
TENANT_ID: {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"},
|
||||||
"loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
|
"loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,65 +654,28 @@ def seed_client_access(db):
|
|||||||
|
|
||||||
|
|
||||||
def seed_costing_workspace(db):
|
def seed_costing_workspace(db):
|
||||||
existing = db.scalar(select(RawMaterial.id))
|
workbook = _load_workbook()
|
||||||
if existing is not None:
|
raw_material_rows = _read_raw_material_rows(workbook)
|
||||||
return
|
mix_rows = _read_mix_rows(workbook)
|
||||||
|
product_rows = _read_product_rows(workbook)
|
||||||
|
|
||||||
tenant_id = "hunter-premium-produce"
|
raw_material_map = _upsert_raw_materials(db, raw_material_rows)
|
||||||
|
_upsert_process_rules(db, product_rows)
|
||||||
|
_upsert_packaging_and_freight_rules(db, product_rows)
|
||||||
|
|
||||||
maize = RawMaterial(tenant_id=tenant_id, name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
mix_cache: dict[tuple[str, str], Mix] = {}
|
||||||
barley = RawMaterial(tenant_id=tenant_id, name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
for mix_row in mix_rows.values():
|
||||||
acid_buf = RawMaterial(tenant_id=tenant_id, name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active")
|
mix = _upsert_mix(
|
||||||
|
db,
|
||||||
maize.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
|
client_name=mix_row["client_name"],
|
||||||
barley.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
|
mix_name=mix_row["name"],
|
||||||
acid_buf.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=39, waste_percentage=0.0, effective_date=date(2026, 4, 1)))
|
ingredients=mix_row["ingredients"],
|
||||||
|
raw_material_map=raw_material_map,
|
||||||
db.add_all([maize, barley, acid_buf])
|
mix_cache=mix_cache,
|
||||||
db.flush()
|
|
||||||
|
|
||||||
db.add_all(
|
|
||||||
[
|
|
||||||
ProcessCostRule(tenant_id=tenant_id, process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0),
|
|
||||||
ProcessCostRule(tenant_id=tenant_id, process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0),
|
|
||||||
PackagingCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63),
|
|
||||||
PackagingCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5),
|
|
||||||
FreightCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45),
|
|
||||||
FreightCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
mix = Mix(tenant_id=tenant_id, client_name="Hunter Premium Produce", name="Hunter Orchard Blend", status="active", version=1, notes="Seed recipe for MVP")
|
|
||||||
db.add(mix)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
db.add_all(
|
|
||||||
[
|
|
||||||
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
|
|
||||||
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95),
|
|
||||||
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
db.add(
|
|
||||||
Product(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
client_name="Hunter Premium Produce",
|
|
||||||
item_id="SKU-001",
|
|
||||||
name="Hunter Orchard Blend 20kg",
|
|
||||||
mix_id=mix.id,
|
|
||||||
sale_type="standard",
|
|
||||||
own_bag=False,
|
|
||||||
unit_of_measure="20kg bag",
|
|
||||||
items_per_pallet=50,
|
|
||||||
bagging_process="standard_bagging",
|
|
||||||
distributor_margin=0.225,
|
|
||||||
wholesale_margin=0.18,
|
|
||||||
notes="Reference product for formula parity work",
|
|
||||||
)
|
)
|
||||||
)
|
mix_cache[(mix_row["client_name"], mix_row["name"])] = mix
|
||||||
|
|
||||||
|
_upsert_products(db, product_rows, mix_cache, raw_material_map)
|
||||||
|
|
||||||
|
|
||||||
def seed_if_empty():
|
def seed_if_empty():
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def calculate_raw_material_cost(raw_material: RawMaterial, price: RawMaterialPri
|
|||||||
|
|
||||||
|
|
||||||
def get_active_price(raw_material: RawMaterial) -> RawMaterialPriceVersion | None:
|
def get_active_price(raw_material: RawMaterial) -> RawMaterialPriceVersion | None:
|
||||||
active_prices = [price for price in raw_material.price_versions if price.status == "active"]
|
active_prices = [price for price in raw_material.price_versions if price.status == "active" and price.market_value > 0]
|
||||||
if not active_prices:
|
if not active_prices:
|
||||||
return None
|
return None
|
||||||
active_prices.sort(key=lambda item: item.effective_date, reverse=True)
|
active_prices.sort(key=lambda item: item.effective_date, reverse=True)
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "data-entry-app-backend"
|
name = "data-entry-app-backend"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
description = "Costing platform MVP backend"
|
description = "Costing platform MVP backend"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1.0",
|
"fastapi>=0.115,<1.0",
|
||||||
|
"openpyxl>=3.1,<4.0",
|
||||||
"uvicorn[standard]>=0.30,<1.0",
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
"sqlalchemy>=2.0,<3.0",
|
"sqlalchemy>=2.0,<3.0",
|
||||||
"pydantic>=2.8,<3.0",
|
"pydantic>=2.8,<3.0",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from app.models.mix import Mix, MixIngredient
|
|||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||||
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
|
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
|
||||||
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
|
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost, serialize_raw_material
|
||||||
from app.services.mix_calculator_service import calculate_mix_calculator_preview
|
from app.services.mix_calculator_service import calculate_mix_calculator_preview
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +38,15 @@ def test_calculate_raw_material_cost():
|
|||||||
assert result.cost_per_kg == 0.51
|
assert result.cost_per_kg == 0.51
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_raw_material_ignores_non_positive_active_price():
|
||||||
|
raw_material = RawMaterial(name="Apple Flavouring", unit_of_measure="kg", kg_per_unit=1, status="active")
|
||||||
|
raw_material.price_versions.append(RawMaterialPriceVersion(market_value=0, waste_percentage=0, effective_date=date(2026, 4, 1), status="active"))
|
||||||
|
|
||||||
|
serialized = serialize_raw_material(raw_material)
|
||||||
|
|
||||||
|
assert serialized["current_price"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_mix_and_product_cost_breakdown():
|
def test_mix_and_product_cost_breakdown():
|
||||||
db = build_session()
|
db = build_session()
|
||||||
|
|
||||||
@@ -286,14 +295,17 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
|
|
||||||
options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers)
|
options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers)
|
||||||
assert options_response.status_code == 200
|
assert options_response.status_code == 200
|
||||||
assert options_response.json()["products"][0]["product_name"] == "Hunter Orchard Blend 20kg"
|
options_payload = options_response.json()
|
||||||
|
assert len(options_payload["products"]) >= 100
|
||||||
|
seeded_product = next(product for product in options_payload["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg")
|
||||||
|
assert seeded_product["unit_size_kg"] == 20
|
||||||
|
|
||||||
create_response = client.post(
|
create_response = client.post(
|
||||||
"/api/mix-calculator",
|
"/api/mix-calculator",
|
||||||
json={
|
json={
|
||||||
"mix_date": "2026-04-29",
|
"mix_date": "2026-04-29",
|
||||||
"client_name": "Hunter Premium Produce",
|
"client_name": seeded_product["client_name"],
|
||||||
"product_id": 1,
|
"product_id": seeded_product["product_id"],
|
||||||
"batch_size_kg": 560,
|
"batch_size_kg": 560,
|
||||||
"prepared_by_name": "Amelia Hart",
|
"prepared_by_name": "Amelia Hart",
|
||||||
"notes": "Morning production run",
|
"notes": "Morning production run",
|
||||||
@@ -302,9 +314,11 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
)
|
)
|
||||||
assert create_response.status_code == 201
|
assert create_response.status_code == 201
|
||||||
created = create_response.json()
|
created = create_response.json()
|
||||||
|
assert created["product_name"] == seeded_product["product_name"]
|
||||||
assert created["session_number"].startswith("HPP-20260429-")
|
assert created["session_number"].startswith("HPP-20260429-")
|
||||||
assert created["total_bags"] == 28
|
assert created["total_bags"] == 28
|
||||||
assert created["lines"][0]["required_kg"] == 360
|
assert len(created["lines"]) > 0
|
||||||
|
assert created["lines"][0]["required_kg"] > 0
|
||||||
|
|
||||||
patch_response = client.patch(
|
patch_response = client.patch(
|
||||||
f"/api/mix-calculator/{created['id']}",
|
f"/api/mix-calculator/{created['id']}",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "data-entry-app-frontend",
|
"name": "data-entry-app-frontend",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.png" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
|
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchItems: SearchItem[] = [
|
const baseSearchItems: SearchItem[] = [
|
||||||
{
|
{
|
||||||
href: '/',
|
href: '/',
|
||||||
label: 'Open Dashboard',
|
label: 'Open Dashboard',
|
||||||
@@ -104,10 +104,12 @@
|
|||||||
let quickMenuOpen = $state(false);
|
let quickMenuOpen = $state(false);
|
||||||
let userMenuOpen = $state(false);
|
let userMenuOpen = $state(false);
|
||||||
let navOpen = $state(false);
|
let navOpen = $state(false);
|
||||||
let workingDocumentsExpanded = $state(true);
|
let workingDocumentsExpanded = $state(false);
|
||||||
let showBottomNav = $state(false);
|
let showBottomNav = $state(false);
|
||||||
let isRestoringSession = $state(false);
|
let isRestoringSession = $state(false);
|
||||||
let restoredToken = $state<string | null>(null);
|
let restoredToken = $state<string | null>(null);
|
||||||
|
let seededSearchItems = $state<SearchItem[]>([]);
|
||||||
|
let seededSearchToken = $state<string | null>(null);
|
||||||
let paletteInput: HTMLInputElement | null = $state(null);
|
let paletteInput: HTMLInputElement | null = $state(null);
|
||||||
const appVersion = `v${packageInfo.version}`;
|
const appVersion = `v${packageInfo.version}`;
|
||||||
const releaseStage = 'Alpha';
|
const releaseStage = 'Alpha';
|
||||||
@@ -138,6 +140,10 @@
|
|||||||
...visibleWorkingDocumentItems.slice(0, 2)
|
...visibleWorkingDocumentItems.slice(0, 2)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
const workingDocumentsActive = $derived(
|
||||||
|
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
|
||||||
|
);
|
||||||
|
const searchItems = $derived([...baseSearchItems, ...seededSearchItems]);
|
||||||
|
|
||||||
function matchesRoute(href: string, pathname: string) {
|
function matchesRoute(href: string, pathname: string) {
|
||||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||||
@@ -255,6 +261,61 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const hydrated = $sessionHydrated;
|
||||||
|
const session = $clientSession;
|
||||||
|
const token = session?.token ?? null;
|
||||||
|
|
||||||
|
if (!hydrated || !session || !token) {
|
||||||
|
seededSearchItems = [];
|
||||||
|
seededSearchToken = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seededSearchToken === token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seededSearchToken = token;
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]),
|
||||||
|
hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]),
|
||||||
|
hasModuleAccess(session, 'mix_calculator') ? api.mixCalculatorSessions() : Promise.resolve([])
|
||||||
|
])
|
||||||
|
.then(([products, mixes, sessions]) => {
|
||||||
|
if (seededSearchToken !== token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seededSearchItems = [
|
||||||
|
...products.map((product) => ({
|
||||||
|
href: '/products',
|
||||||
|
label: product.name,
|
||||||
|
description: `Product · ${product.client_name} · ${product.mix_name}`,
|
||||||
|
keywords: `product ${product.name} ${product.client_name} ${product.mix_name} ${product.unit_of_measure}`
|
||||||
|
})),
|
||||||
|
...mixes.map((mix) => ({
|
||||||
|
href: `/mixes/${mix.id}`,
|
||||||
|
label: mix.name,
|
||||||
|
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
|
||||||
|
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
|
||||||
|
})),
|
||||||
|
...sessions.map((savedSession) => ({
|
||||||
|
href: `/mix-calculator/${savedSession.id}`,
|
||||||
|
label: `${savedSession.session_number} · ${savedSession.product_name}`,
|
||||||
|
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
|
||||||
|
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seededSearchToken === token) {
|
||||||
|
seededSearchItems = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($sessionHydrated && !$clientSession && !isRootRoute) {
|
if ($sessionHydrated && !$clientSession && !isRootRoute) {
|
||||||
goto('/', { replaceState: true });
|
goto('/', { replaceState: true });
|
||||||
@@ -370,11 +431,15 @@
|
|||||||
<button
|
<button
|
||||||
aria-controls="working-documents-nav"
|
aria-controls="working-documents-nav"
|
||||||
aria-expanded={workingDocumentsExpanded}
|
aria-expanded={workingDocumentsExpanded}
|
||||||
|
class:active={workingDocumentsActive}
|
||||||
class="nav-group-toggle"
|
class="nav-group-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
||||||
>
|
>
|
||||||
<span class="nav-group-label">Working Documents</span>
|
<span class="nav-group-toggle-copy">
|
||||||
|
<span class="nav-icon muted">WD</span>
|
||||||
|
<span>Working Docs</span>
|
||||||
|
</span>
|
||||||
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
||||||
</button>
|
</button>
|
||||||
{#if workingDocumentsExpanded}
|
{#if workingDocumentsExpanded}
|
||||||
@@ -421,37 +486,12 @@
|
|||||||
<div class="topbar-middle">
|
<div class="topbar-middle">
|
||||||
<button class="search-box topbar-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
|
<button class="search-box topbar-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
|
||||||
<span class="search-icon"></span>
|
<span class="search-icon"></span>
|
||||||
<span class="search-placeholder">Search pages, mixes, products, and settings...</span>
|
<span class="search-placeholder">Search products, mixes, sessions, and pages...</span>
|
||||||
<kbd>/</kbd>
|
<kbd>/</kbd>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<div class="menu-wrap">
|
|
||||||
<button
|
|
||||||
class="action-button"
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
quickMenuOpen = !quickMenuOpen;
|
|
||||||
userMenuOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Quick Actions
|
|
||||||
<span class:open={quickMenuOpen} class="chevron"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if quickMenuOpen}
|
|
||||||
<div class="menu-panel">
|
|
||||||
<a href="/mixes">Open mix costing</a>
|
|
||||||
<a href="/mixes/new">Create mix worksheet</a>
|
|
||||||
<a href="/mix-calculator">Open mix calculator</a>
|
|
||||||
<a href="/mix-calculator/new">Create mix session</a>
|
|
||||||
<a href="/products">Review delivered pricing</a>
|
|
||||||
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="menu-wrap user-menu-wrap">
|
<div class="menu-wrap user-menu-wrap">
|
||||||
<button
|
<button
|
||||||
aria-expanded={userMenuOpen}
|
aria-expanded={userMenuOpen}
|
||||||
@@ -465,7 +505,7 @@
|
|||||||
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
|
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
|
||||||
<span class="user-trigger-copy">
|
<span class="user-trigger-copy">
|
||||||
<span class="workspace-label">{$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
|
<span class="workspace-label">{$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
|
||||||
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.email : 'Sign in required') : 'Restoring workspace access'}</strong>
|
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.name || 'Client account' : 'Sign in required') : 'Restoring workspace access'}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span class:open={userMenuOpen} class="chevron"></span>
|
<span class:open={userMenuOpen} class="chevron"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -514,6 +554,33 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-fab-wrap">
|
||||||
|
{#if quickMenuOpen}
|
||||||
|
<div class="menu-panel quick-fab-panel">
|
||||||
|
<a href="/mixes">Open mix costing</a>
|
||||||
|
<a href="/mixes/new">Create mix worksheet</a>
|
||||||
|
<a href="/mix-calculator">Open mix calculator</a>
|
||||||
|
<a href="/mix-calculator/new">Create mix session</a>
|
||||||
|
<a href="/products">Review delivered pricing</a>
|
||||||
|
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-expanded={quickMenuOpen}
|
||||||
|
aria-label="Open quick access menu"
|
||||||
|
class="quick-fab"
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
quickMenuOpen = !quickMenuOpen;
|
||||||
|
userMenuOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
|
||||||
|
<span>Quick Access</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showBottomNav}
|
{#if showBottomNav}
|
||||||
@@ -629,11 +696,15 @@
|
|||||||
<button
|
<button
|
||||||
aria-controls="drawer-working-documents-nav"
|
aria-controls="drawer-working-documents-nav"
|
||||||
aria-expanded={workingDocumentsExpanded}
|
aria-expanded={workingDocumentsExpanded}
|
||||||
|
class:active={workingDocumentsActive}
|
||||||
class="nav-group-toggle drawer-group-toggle"
|
class="nav-group-toggle drawer-group-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
||||||
>
|
>
|
||||||
<span class="drawer-group-label">Working Documents</span>
|
<span class="nav-group-toggle-copy">
|
||||||
|
<span class="nav-icon muted">WD</span>
|
||||||
|
<span>Working Documents</span>
|
||||||
|
</span>
|
||||||
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
||||||
</button>
|
</button>
|
||||||
{#if workingDocumentsExpanded}
|
{#if workingDocumentsExpanded}
|
||||||
@@ -710,7 +781,7 @@
|
|||||||
>
|
>
|
||||||
<div class="palette-input-row">
|
<div class="palette-input-row">
|
||||||
<span class="search-icon"></span>
|
<span class="search-icon"></span>
|
||||||
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search pages, workflows, and pricing views..." />
|
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search products, mixes, sessions, and pages..." />
|
||||||
<kbd>Esc</kbd>
|
<kbd>Esc</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -785,7 +856,7 @@
|
|||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 228px minmax(0, 1fr);
|
grid-template-columns: 244px minmax(0, 1fr);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,25 +1074,31 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 0.68rem;
|
padding: 0.72rem 0.68rem;
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 0.82rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: #304038;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group-label,
|
.nav-group-toggle:hover,
|
||||||
.drawer-group-label {
|
.nav-group-toggle.active {
|
||||||
margin: 0;
|
background: var(--green-soft);
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group-label {
|
.nav-group-toggle.active {
|
||||||
padding: 0;
|
color: var(--green-deep);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-toggle-copy {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.68rem;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-sublist {
|
.nav-sublist {
|
||||||
@@ -1141,9 +1218,12 @@
|
|||||||
|
|
||||||
.topbar-middle {
|
.topbar-middle {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-search {
|
.topbar-search {
|
||||||
|
width: min(100%, 36rem);
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
@@ -1186,15 +1266,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.62rem;
|
|
||||||
border-radius: 0.88rem;
|
|
||||||
padding: 0.68rem 0.84rem;
|
|
||||||
color: #304038;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-trigger {
|
.user-trigger {
|
||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1291,6 +1362,71 @@
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-fab-wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: max(1rem, env(safe-area-inset-right));
|
||||||
|
bottom: max(1rem, env(safe-area-inset-bottom));
|
||||||
|
z-index: 46;
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.72rem;
|
||||||
|
padding: 0.88rem 1.05rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 18px 36px rgba(23, 75, 45, 0.26);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fab-panel {
|
||||||
|
position: static;
|
||||||
|
min-width: 15rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fab-plus {
|
||||||
|
position: relative;
|
||||||
|
width: 0.92rem;
|
||||||
|
height: 0.92rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fab-plus::before,
|
||||||
|
.quick-fab-plus::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0.92rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: currentColor;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fab-plus::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fab-plus.open::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fab-plus.open::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
.menu-panel a,
|
.menu-panel a,
|
||||||
.menu-panel button {
|
.menu-panel button {
|
||||||
padding: 0.72rem 0.78rem;
|
padding: 0.72rem 0.78rem;
|
||||||
@@ -1482,6 +1618,10 @@
|
|||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-fab-wrap {
|
||||||
|
bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-nav a,
|
.bottom-nav a,
|
||||||
.bottom-nav button {
|
.bottom-nav button {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1599,11 +1739,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer-group-toggle {
|
.drawer-group-toggle {
|
||||||
padding: 0 0.2rem;
|
padding-inline: 0.2rem;
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-group-label {
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-sublist {
|
.drawer-sublist {
|
||||||
@@ -1649,6 +1785,7 @@
|
|||||||
|
|
||||||
.topbar-middle {
|
.topbar-middle {
|
||||||
order: 3;
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
|
|||||||
@@ -598,6 +598,11 @@
|
|||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 140ms ease,
|
||||||
|
box-shadow 140ms ease,
|
||||||
|
background-color 140ms ease,
|
||||||
|
border-color 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
@@ -611,6 +616,22 @@
|
|||||||
color: #304038;
|
color: #304038;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-button:hover:not(:disabled),
|
||||||
|
.secondary-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 14px 28px rgba(23, 75, 45, 0.22);
|
||||||
|
filter: brightness(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover:not(:disabled) {
|
||||||
|
border-color: #9fb0a6;
|
||||||
|
background: #f6faf7;
|
||||||
|
box-shadow: 0 10px 22px rgba(24, 38, 29, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -100,6 +100,22 @@
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function greetingForAst() {
|
||||||
|
const astHour = Number(
|
||||||
|
new Intl.DateTimeFormat('en-AU', {
|
||||||
|
hour: 'numeric',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Australia/Brisbane'
|
||||||
|
}).format(new Date())
|
||||||
|
);
|
||||||
|
|
||||||
|
return astHour < 12 ? 'Good morning' : 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstName(name: string | null | undefined) {
|
||||||
|
return name?.trim().split(/\s+/)[0] ?? 'there';
|
||||||
|
}
|
||||||
|
|
||||||
function findHighestProduct(products: ProductCostBreakdown[]) {
|
function findHighestProduct(products: ProductCostBreakdown[]) {
|
||||||
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
|
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
|
||||||
}
|
}
|
||||||
@@ -393,7 +409,7 @@
|
|||||||
<p class="eyebrow">Client Workspace</p>
|
<p class="eyebrow">Client Workspace</p>
|
||||||
<span class="release-pill">{releaseStage}</span>
|
<span class="release-pill">{releaseStage}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2>Hunter Premium Produce costing overview.</h2>
|
<h2>{greetingForAst()}, {firstName($clientSession?.name)}</h2>
|
||||||
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p>
|
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user