Deployment fixes, add HPP logo
This commit is contained in:
@@ -12,4 +12,6 @@ PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
|
||||
INTERNAL_API_BASE_URL=http://backend:8000
|
||||
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
|
||||
CLIENTS_APP_PORT=808
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||
DATABASE_URL=sqlite:////data/data_entry_app.db
|
||||
|
||||
@@ -12,4 +12,6 @@ PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
|
||||
INTERNAL_API_BASE_URL=http://backend:8000
|
||||
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
|
||||
CLIENTS_APP_PORT=8081
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||
DATABASE_URL=sqlite:////data/data_entry_app.db
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
APP_NAME=Lean 101 Clients API
|
||||
CLIENT_NAME=Hunter Premium Produce
|
||||
CLIENT_EMAIL=operator@example.com
|
||||
CLIENT_PASSWORD=replace-with-strong-password
|
||||
CLIENT_TENANT_ID=hunter-premium-produce
|
||||
ADMIN_NAME=Lean 101
|
||||
ADMIN_EMAIL=admin@lean101.local
|
||||
ADMIN_PASSWORD=replace-with-strong-password
|
||||
AUTH_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
# Postgres credentials. The compose file builds DATABASE_URL from these
|
||||
# so you do not need to set DATABASE_URL explicitly. Override DATABASE_URL
|
||||
# only if you want to point at a managed Postgres outside the compose stack.
|
||||
POSTGRES_USER=lean101
|
||||
POSTGRES_PASSWORD=replace-with-a-long-random-password
|
||||
POSTGRES_DB=lean101
|
||||
# DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME
|
||||
|
||||
ORIGIN=https://clients.lean-101.com.au
|
||||
PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
|
||||
INTERNAL_API_BASE_URL=http://backend:8000
|
||||
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
|
||||
CLIENTS_APP_PORT=8081
|
||||
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||
@@ -15,4 +15,6 @@ frontend/node_modules/
|
||||
*.pyd
|
||||
*.sqlite3
|
||||
*.db
|
||||
.env.production
|
||||
.env.alpha
|
||||
|
||||
|
||||
@@ -60,6 +60,40 @@ This follows the same external pattern as your website container:
|
||||
|
||||
If your server already has a host-level nginx handling domains and TLS, use `deploy/nginx/clients.lean-101.proxy.conf` as the upstream template and point the domain at `http://127.0.0.1:8081`.
|
||||
|
||||
## Production deployment (Postgres + Digital Ocean)
|
||||
|
||||
`docker-compose.production.yml` provisions a managed-style stack with a containerised Postgres 16 service replacing the SQLite file used in alpha. The PowerShell script `deploy/Deploy.ps1` drives both first-time bootstrap and incremental updates from a Windows workstation against a Digital Ocean droplet.
|
||||
|
||||
1. **One-time droplet prep**: install Docker Engine + the compose plugin (`apt install docker.io docker-compose-plugin` or the official Docker repo). Open inbound 80/443 on your reverse proxy and forward to `127.0.0.1:${CLIENTS_APP_PORT}` (default 8081).
|
||||
2. **Local secrets**: copy the example env and fill in real secrets — strong `POSTGRES_PASSWORD`, `AUTH_SECRET`, `CLIENT_PASSWORD`, `ADMIN_PASSWORD`. The compose file refuses to start without them.
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.production.example .env.production
|
||||
notepad .env.production
|
||||
```
|
||||
|
||||
3. **First deploy** (clones the repo on the droplet, uploads the env file, brings the stack up):
|
||||
|
||||
```powershell
|
||||
./deploy/Deploy.ps1 `
|
||||
-RemoteHost 203.0.113.10 `
|
||||
-Bootstrap `
|
||||
-RepoUrl git@github.com:ponzischeme89/data-entry-app.git `
|
||||
-Seed
|
||||
```
|
||||
|
||||
4. **Subsequent updates** (the same script — pulls latest `main`, rebuilds, and rolls containers without touching the Postgres volume):
|
||||
|
||||
```powershell
|
||||
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
|
||||
```
|
||||
|
||||
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key.
|
||||
|
||||
5. **Database**: the backend reads `DATABASE_URL`. The production compose file synthesises it as `postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}` so you only need to set the three `POSTGRES_*` vars. Override `DATABASE_URL` directly if you point at a managed Postgres (e.g. DigitalOcean managed databases).
|
||||
|
||||
The schema is auto-managed — `app/db/migrations.py` runs at backend startup and is idempotent across SQLite and Postgres. To migrate alpha SQLite data into the new Postgres instance, dump tables to CSV from the alpha container and import via `\copy` in `psql`; there is no automatic SQLite → Postgres path.
|
||||
|
||||
## Backend
|
||||
|
||||
Create a virtual environment, install dependencies, then run:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-entry-app-backend"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
description = "Costing platform MVP backend"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"sqlalchemy>=2.0,<3.0",
|
||||
"pydantic>=2.8,<3.0",
|
||||
"pytest>=8.0,<9.0",
|
||||
"psycopg[binary]>=3.2,<4.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs `docker compose` against `docker-compose.production.yml` on the remote
|
||||
host. The same script handles first-time bootstrap and subsequent updates:
|
||||
|
||||
* On bootstrap (-Bootstrap): creates the remote directory, clones the
|
||||
repo (or updates if already present), uploads the local env file, and
|
||||
brings the stack up with `docker compose ... up -d --build`.
|
||||
|
||||
* On update (default): SSHes to the host, fetches the requested branch,
|
||||
uploads a refreshed env file (if changed), then runs
|
||||
`docker compose ... up -d --build` followed by a healthcheck.
|
||||
|
||||
The script never executes destructive commands without asking, except for
|
||||
recreating containers (which preserves the named Postgres volume).
|
||||
|
||||
.PARAMETER RemoteHost
|
||||
Hostname or IP of the Digital Ocean droplet. Required.
|
||||
|
||||
.PARAMETER RemoteUser
|
||||
SSH user on the droplet. Defaults to `root`.
|
||||
|
||||
.PARAMETER RemotePath
|
||||
Absolute path on the droplet where the repo lives. Defaults to
|
||||
`/srv/lean101-clients`.
|
||||
|
||||
.PARAMETER Branch
|
||||
Git branch to deploy. Defaults to `main`.
|
||||
|
||||
.PARAMETER RepoUrl
|
||||
Git URL used during bootstrap when the remote directory is empty.
|
||||
Required only with -Bootstrap.
|
||||
|
||||
.PARAMETER EnvFile
|
||||
Local path to the env file that should land on the droplet as
|
||||
`<RemotePath>/.env.production`. Defaults to `.env.production`.
|
||||
|
||||
.PARAMETER SshKey
|
||||
Optional path to an SSH private key. If omitted, the script relies on
|
||||
ssh-agent / default keys.
|
||||
|
||||
.PARAMETER ComposeFile
|
||||
Compose file name on the remote host. Defaults to
|
||||
`docker-compose.production.yml`.
|
||||
|
||||
.PARAMETER Bootstrap
|
||||
Run first-time setup (clone, upload env, build, up).
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Pass `--no-build` to docker compose (use when only env changed).
|
||||
|
||||
.PARAMETER Seed
|
||||
Run `python -m app.seed` inside the backend container after the stack is up.
|
||||
|
||||
.PARAMETER Logs
|
||||
After deploy, tail logs for ~20 lines so you can verify the stack came up.
|
||||
|
||||
.EXAMPLE
|
||||
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git
|
||||
|
||||
.EXAMPLE
|
||||
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $RemoteHost,
|
||||
[string] $RemoteUser = "root",
|
||||
[string] $RemotePath = "/srv/lean101-clients",
|
||||
[string] $Branch = "main",
|
||||
[string] $RepoUrl,
|
||||
[string] $EnvFile = ".env.production",
|
||||
[string] $SshKey,
|
||||
[string] $ComposeFile = "docker-compose.production.yml",
|
||||
[switch] $Bootstrap,
|
||||
[switch] $SkipBuild,
|
||||
[switch] $Seed,
|
||||
[switch] $Logs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Write-Step($message) {
|
||||
Write-Host "==> $message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Warn($message) {
|
||||
Write-Host "!! $message" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.ScriptName
|
||||
if (-not $scriptDir) { $scriptDir = $PSScriptRoot }
|
||||
return (Resolve-Path (Join-Path $scriptDir "..")).Path
|
||||
}
|
||||
|
||||
$RepoRoot = Resolve-RepoRoot
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
|
||||
if (-not (Test-Path $envPath)) {
|
||||
throw "Env file not found at '$envPath'. Copy .env.production.example to $EnvFile and fill in production secrets first."
|
||||
}
|
||||
|
||||
$sshTarget = "$RemoteUser@$RemoteHost"
|
||||
$sshOpts = @("-o", "StrictHostKeyChecking=accept-new")
|
||||
if ($SshKey) { $sshOpts += @("-i", $SshKey) }
|
||||
|
||||
function Invoke-Ssh([string] $remoteCommand) {
|
||||
& ssh @sshOpts $sshTarget $remoteCommand
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Remote command failed (exit $LASTEXITCODE): $remoteCommand"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Scp([string] $localPath, [string] $remoteDest) {
|
||||
& scp @sshOpts $localPath "$($sshTarget):$remoteDest"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "scp failed for $localPath -> $remoteDest"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Verifying SSH connectivity to $sshTarget"
|
||||
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
|
||||
|
||||
Write-Step "Verifying Docker is installed on the droplet"
|
||||
Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version"
|
||||
|
||||
if ($Bootstrap) {
|
||||
if (-not $RepoUrl) {
|
||||
throw "-RepoUrl is required when using -Bootstrap."
|
||||
}
|
||||
Write-Step "Bootstrapping $RemotePath from $RepoUrl ($Branch)"
|
||||
$bootstrapScript = @"
|
||||
set -euo pipefail
|
||||
mkdir -p '$RemotePath'
|
||||
cd '$RemotePath'
|
||||
if [ ! -d .git ]; then
|
||||
git clone --branch '$Branch' '$RepoUrl' .
|
||||
else
|
||||
git remote set-url origin '$RepoUrl'
|
||||
git fetch origin '$Branch'
|
||||
git checkout '$Branch'
|
||||
git reset --hard 'origin/$Branch'
|
||||
fi
|
||||
"@
|
||||
Invoke-Ssh $bootstrapScript
|
||||
} else {
|
||||
Write-Step "Updating $RemotePath to latest $Branch"
|
||||
$updateScript = @"
|
||||
set -euo pipefail
|
||||
cd '$RemotePath'
|
||||
git fetch origin '$Branch'
|
||||
git checkout '$Branch'
|
||||
git reset --hard 'origin/$Branch'
|
||||
"@
|
||||
Invoke-Ssh $updateScript
|
||||
}
|
||||
|
||||
Write-Step "Uploading $EnvFile to $RemotePath/.env.production"
|
||||
Invoke-Scp $envPath "$RemotePath/.env.production"
|
||||
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
|
||||
|
||||
$composeArgs = @(
|
||||
"--env-file", ".env.production",
|
||||
"-f", $ComposeFile
|
||||
) -join " "
|
||||
|
||||
$buildFlag = if ($SkipBuild) { "" } else { "--build" }
|
||||
|
||||
Write-Step "Pulling base images"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true"
|
||||
|
||||
Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans"
|
||||
|
||||
Write-Step "Waiting for backend healthcheck"
|
||||
$healthScript = @"
|
||||
set -e
|
||||
cd '$RemotePath'
|
||||
for attempt in `$(seq 1 30); do
|
||||
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing)
|
||||
case "`$status" in
|
||||
healthy|running)
|
||||
echo "backend is `$status"
|
||||
exit 0;;
|
||||
*)
|
||||
printf '.'
|
||||
sleep 4;;
|
||||
esac
|
||||
done
|
||||
echo
|
||||
echo 'backend did not become healthy in time' >&2
|
||||
docker compose $composeArgs ps backend
|
||||
exit 1
|
||||
"@
|
||||
Invoke-Ssh $healthScript
|
||||
|
||||
if ($Seed) {
|
||||
Write-Step "Seeding reference data"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs exec -T backend python -m app.seed"
|
||||
}
|
||||
|
||||
Write-Step "Final container status"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs ps"
|
||||
|
||||
if ($Logs) {
|
||||
Write-Step "Recent logs (last 60 lines)"
|
||||
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs logs --tail=60"
|
||||
}
|
||||
|
||||
Write-Host "Deployment complete." -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -39,6 +39,8 @@ services:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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:-8081}:80"
|
||||
volumes:
|
||||
- ./deploy/nginx/clients.lean-101.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
volumes:
|
||||
clients_db_data:
|
||||
@@ -39,6 +39,8 @@ services:
|
||||
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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "data-entry-app-frontend",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "data-entry-app-frontend",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.2.0",
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "data-entry-app-frontend",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Lean101Logo from '$lib/components/Lean101Logo.svelte';
|
||||
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
|
||||
import { featureFlags } from '$lib/features';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import packageInfo from '../../../package.json';
|
||||
|
||||
@@ -24,7 +24,12 @@
|
||||
};
|
||||
|
||||
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
|
||||
const mixCalculatorItem: NavItem = { href: '/mix-calculator', label: 'Mix Calculator', shortLabel: 'MC', moduleKey: 'mix_calculator' };
|
||||
const mixCalculatorItem: NavItem = {
|
||||
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
|
||||
label: 'Mix Calculator',
|
||||
shortLabel: 'MC',
|
||||
moduleKey: 'mix_calculator'
|
||||
};
|
||||
const workingDocumentItems: NavItem[] = [
|
||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
|
||||
@@ -64,12 +69,16 @@
|
||||
description: 'Start a new costing worksheet for Hunter Premium Produce.',
|
||||
keywords: 'new mix create worksheet hunter premium produce formula'
|
||||
},
|
||||
{
|
||||
href: '/mix-calculator',
|
||||
label: 'Open Mix Calculator',
|
||||
description: 'Review saved production sessions and batch calculations.',
|
||||
keywords: 'mix calculator production sessions batch bags client product'
|
||||
},
|
||||
...(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',
|
||||
@@ -281,7 +290,9 @@
|
||||
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([])
|
||||
featureFlags.mixCalculatorSessionHistory && hasModuleAccess(session, 'mix_calculator')
|
||||
? api.mixCalculatorSessions()
|
||||
: Promise.resolve([])
|
||||
])
|
||||
.then(([products, mixes, sessions]) => {
|
||||
if (seededSearchToken !== token) {
|
||||
@@ -382,7 +393,7 @@
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/">
|
||||
<Lean101Logo className="sidebar-logo" showTagline={false} />
|
||||
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -421,7 +432,9 @@
|
||||
|
||||
{#if visibleMixCalculatorItem}
|
||||
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}>
|
||||
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span>
|
||||
<span class="nav-icon">
|
||||
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span>{visibleMixCalculatorItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
@@ -560,7 +573,7 @@
|
||||
<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={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>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>
|
||||
@@ -687,7 +700,9 @@
|
||||
|
||||
{#if visibleMixCalculatorItem}
|
||||
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span>
|
||||
<span class="nav-icon">
|
||||
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span>{visibleMixCalculatorItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
@@ -918,16 +933,19 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sidebar :global(.sidebar-logo) {
|
||||
width: min(100%, 12.2rem);
|
||||
.nav-icon-mask {
|
||||
display: inline-block;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
background-color: currentColor;
|
||||
-webkit-mask: var(--nav-icon-url) center / contain no-repeat;
|
||||
mask: var(--nav-icon-url) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.sidebar :global(.sidebar-logo .logo-copy strong) {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.sidebar :global(.sidebar-logo .logo-mark) {
|
||||
width: 2.6rem;
|
||||
.sidebar .sidebar-logo {
|
||||
width: min(100%, 13.5rem);
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-toggle,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { featureFlags } from '$lib/features';
|
||||
import { clientSession, hasModuleAccess } from '$lib/session';
|
||||
import type {
|
||||
MixCalculatorCreateInput,
|
||||
@@ -169,7 +170,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSession(mode: 'update' | 'create') {
|
||||
function clearForm() {
|
||||
clientName = options.clients[0] ?? '';
|
||||
productId = 0;
|
||||
mixDate = todayIso;
|
||||
batchSizeKg = '';
|
||||
preparedByName = $clientSession?.name ?? '';
|
||||
notes = '';
|
||||
preview = null;
|
||||
formError = '';
|
||||
formSuccess = '';
|
||||
}
|
||||
|
||||
function printPreview() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
@@ -182,7 +201,8 @@
|
||||
? await api.updateMixCalculatorSession(initialSession.id, payload)
|
||||
: await api.createMixCalculatorSession(payload);
|
||||
|
||||
await goto(`/mix-calculator/${saved.id}`);
|
||||
const target = destination === 'print' ? `/mix-calculator/${saved.id}/print` : `/mix-calculator/${saved.id}`;
|
||||
await goto(target);
|
||||
} catch (error) {
|
||||
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
|
||||
saveLoading = false;
|
||||
@@ -195,17 +215,24 @@
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<h2>Edit access is required to create a new session.</h2>
|
||||
<p>View-only users can open saved sessions from history, but cannot create or update production calculations.</p>
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
{#if featureFlags.mixCalculatorSessionHistory}
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<p class="eyebrow">
|
||||
<span class="eyebrow-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
|
||||
<span>Mix Calculator</span>
|
||||
</p>
|
||||
<h2>{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}</h2>
|
||||
<p>Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="secondary-button" href="/mix-calculator">Session history</a>
|
||||
{#if featureFlags.mixCalculatorSessionHistory}
|
||||
<a class="secondary-button" href="/mix-calculator">Session history</a>
|
||||
{/if}
|
||||
{#if initialSession}
|
||||
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
|
||||
{/if}
|
||||
@@ -288,18 +315,36 @@
|
||||
{#if canEdit}
|
||||
<div class="action-row">
|
||||
<button class="primary-button" disabled={previewLoading || saveLoading} type="button" onclick={calculatePreview}>
|
||||
{previewLoading ? 'Calculating...' : 'Calculate mix'}
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
|
||||
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
|
||||
</button>
|
||||
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}>
|
||||
{saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'}
|
||||
</button>
|
||||
{#if featureFlags.mixCalculatorSessionSave}
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}>
|
||||
{saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'}
|
||||
</button>
|
||||
|
||||
{#if initialSession}
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}>
|
||||
Save as new
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create', 'print')}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
||||
<span>{saveLoading ? 'Saving...' : 'Save & print'}</span>
|
||||
</button>
|
||||
|
||||
{#if initialSession}
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}>
|
||||
Save as new
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
||||
<span>Print</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button class="secondary-button" disabled={previewLoading || saveLoading} type="button" onclick={clearForm}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
@@ -396,6 +441,92 @@
|
||||
{/if}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{#if preview}
|
||||
<section class="print-only" aria-hidden="true">
|
||||
<article class="print-sheet">
|
||||
<header class="print-header">
|
||||
<div>
|
||||
<p class="print-eyebrow">Mix Calculator</p>
|
||||
<h1>{preview.product_name}</h1>
|
||||
<p class="print-subtitle">{preview.client_name} · {preview.mix_name}</p>
|
||||
</div>
|
||||
<div class="print-meta">
|
||||
<div>
|
||||
<span>Mix date</span>
|
||||
<strong>{formatDate(preview.mix_date)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Prepared by</span>
|
||||
<strong>{preview.prepared_by_name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="print-summary">
|
||||
<div>
|
||||
<span>Batch size</span>
|
||||
<strong>{formatNumber(preview.batch_size_kg, 2)}kg</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Total kilograms</span>
|
||||
<strong>{formatNumber(preview.total_kg, 2)}kg</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Total bags</span>
|
||||
<strong>{formatNumber(preview.total_bags, 2)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Unit size</span>
|
||||
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if preview.notes}
|
||||
<section class="print-notes">
|
||||
<h2>Notes</h2>
|
||||
<p>{preview.notes}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if preview.warnings.length}
|
||||
<section class="print-warnings">
|
||||
<h2>Warnings</h2>
|
||||
{#each preview.warnings as warning}
|
||||
<p>{warning}</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="print-table">
|
||||
<div class="print-table-header">
|
||||
<h2>Required raw materials</h2>
|
||||
<span>{preview.product_unit_of_measure} · {formatNumber(preview.product_unit_size_kg, 2)}kg per unit</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raw material</th>
|
||||
<th>Mix %</th>
|
||||
<th>Required kg</th>
|
||||
<th>Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preview.lines as line}
|
||||
<tr>
|
||||
<td>{line.raw_material_name}</td>
|
||||
<td>{formatNumber(line.mix_percentage, 2)}%</td>
|
||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
||||
<td>{line.unit}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -411,6 +542,18 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.eyebrow-icon {
|
||||
display: inline-block;
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
background-color: currentColor;
|
||||
-webkit-mask: var(--button-icon-url) center / contain no-repeat;
|
||||
mask: var(--button-icon-url) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
@@ -593,6 +736,7 @@
|
||||
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);
|
||||
@@ -632,6 +776,16 @@
|
||||
box-shadow: 0 10px 22px rgba(24, 38, 29, 0.08);
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: currentColor;
|
||||
-webkit-mask: var(--button-icon-url) center / contain no-repeat;
|
||||
mask: var(--button-icon-url) center / contain no-repeat;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
@@ -781,4 +935,151 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
:global(body) {
|
||||
background: #fff !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
:global(body *) {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.print-only,
|
||||
.print-only :global(*) {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1.4cm;
|
||||
background: #fff;
|
||||
color: #1a2421;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.print-sheet {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.print-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #cbd6cf;
|
||||
}
|
||||
|
||||
.print-header h1 {
|
||||
margin: 0.25rem 0 0.3rem;
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.print-eyebrow {
|
||||
color: #5f6f67;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-subtitle {
|
||||
color: #5f6f67;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-meta {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.print-meta div,
|
||||
.print-summary div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.print-meta span,
|
||||
.print-summary span,
|
||||
.print-table-header span {
|
||||
color: #5f6f67;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.print-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #cbd6cf;
|
||||
}
|
||||
|
||||
.print-notes,
|
||||
.print-warnings {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid #cbd6cf;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.print-warnings {
|
||||
border-color: #d8a76b;
|
||||
background: #fff6e6;
|
||||
color: #8b5b1e;
|
||||
}
|
||||
|
||||
.print-notes h2,
|
||||
.print-warnings h2,
|
||||
.print-table-header h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.print-table {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.print-table-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.print-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.print-table th,
|
||||
.print-table td {
|
||||
padding: 0.55rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #cbd6cf;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.print-table th {
|
||||
color: #5f6f67;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 1cm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
function flagEnabled(value: string | undefined, defaultValue: boolean): boolean {
|
||||
if (value === undefined || value === null || value.trim() === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export const featureFlags = {
|
||||
mixCalculatorSessionHistory: flagEnabled(env.PUBLIC_MIX_CALCULATOR_SESSION_HISTORY, false),
|
||||
mixCalculatorSessionSave: flagEnabled(env.PUBLIC_MIX_CALCULATOR_SESSION_SAVE, false)
|
||||
};
|
||||
@@ -303,7 +303,7 @@
|
||||
<div class="auth-card auth-card-loading">
|
||||
<div class="auth-header">
|
||||
<div class="client-logo-block">
|
||||
<img class="hero-login-logo" src="/lean101-login-logo.png" alt="Lean 101" />
|
||||
<img class="hero-login-logo" src="/logo-hsf.png" alt="Lean 101" />
|
||||
<div class="client-logo-copy">
|
||||
<p class="eyebrow">Client Workspace</p>
|
||||
<strong>Hunter Premium Produce</strong>
|
||||
@@ -327,7 +327,7 @@
|
||||
|
||||
<div class="auth-footer">
|
||||
<div class="lean-brand">
|
||||
<img class="footer-login-logo" src="/lean101-login-logo.png" alt="Lean 101" />
|
||||
<img class="footer-login-logo" src="/logo-hsf.png" alt="Lean 101" />
|
||||
</div>
|
||||
<div class="auth-meta">
|
||||
<span class="version-badge">
|
||||
@@ -344,7 +344,7 @@
|
||||
<div class="auth-card auth-card-login">
|
||||
<div class="auth-header">
|
||||
<div class="client-logo-block">
|
||||
<img class="hero-login-logo" src="/lean101-login-logo.png" alt="Lean 101" />
|
||||
<img class="hero-login-logo" src="/logo-hsf.png" alt="Lean 101" />
|
||||
<div class="client-logo-copy">
|
||||
<p class="eyebrow">Client Sign-In</p>
|
||||
<strong>Hunter Premium Produce</strong>
|
||||
@@ -389,7 +389,7 @@
|
||||
|
||||
<div class="auth-footer">
|
||||
<div class="lean-brand">
|
||||
<img class="footer-login-logo" src="/lean101-login-logo.png" alt="Lean 101" />
|
||||
<img class="footer-login-logo" src="/logo-hsf.png" alt="Lean 101" />
|
||||
</div>
|
||||
<div class="auth-meta">
|
||||
<span class="version-badge">
|
||||
@@ -798,13 +798,13 @@
|
||||
}
|
||||
|
||||
.hero-login-logo {
|
||||
width: min(100%, 19rem);
|
||||
width: min(100%, 24rem);
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer-login-logo {
|
||||
width: 9.8rem;
|
||||
width: 12rem;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import { featureFlags } from '$lib/features';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
if (!featureFlags.mixCalculatorSessionHistory) {
|
||||
throw redirect(307, '/mix-calculator/new');
|
||||
}
|
||||
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
sessions: []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
|
||||
import { featureFlags } from '$lib/features';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
@@ -11,7 +12,11 @@
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<h2>Session unavailable.</h2>
|
||||
<p>The requested mix calculator session could not be loaded with the current access scope.</p>
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
{#if featureFlags.mixCalculatorSessionHistory}
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
{:else}
|
||||
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MixCalculatorPrintSheet from '$lib/components/MixCalculatorPrintSheet.svelte';
|
||||
import { featureFlags } from '$lib/features';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
@@ -11,7 +12,11 @@
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<h2>Printable session unavailable.</h2>
|
||||
<p>The saved session could not be loaded for printing.</p>
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
{#if featureFlags.mixCalculatorSessionHistory}
|
||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||
{:else}
|
||||
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zM96 80l0 64c0 8.8 7.2 16 16 16l160 0c8.8 0 16-7.2 16-16l0-64c0-8.8-7.2-16-16-16L112 64c-8.8 0-16 7.2-16 16zm32 128a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zm64 32a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm96-32a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM64 304a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm32 128a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm64-128a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm32 128a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm64-96l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32z"/></svg>
|
||||
|
After Width: | Height: | Size: 895 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M128 0C92.7 0 64 28.7 64 64l0 96 64 0 0-96 226.7 0L384 93.3l0 66.7 64 0 0-66.7c0-17-6.7-33.3-18.7-45.3L400 18.7C388 6.7 371.7 0 354.7 0L128 0zM384 352l0 32 0 64-256 0 0-64 0-16 0-16 256 0zm64 32l32 0c17.7 0 32-14.3 32-32l0-96c0-35.3-28.7-64-64-64L64 192c-35.3 0-64 28.7-64 64l0 96c0 17.7 14.3 32 32 32l32 0 0 64c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-64zM432 248a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></svg>
|
||||
|
After Width: | Height: | Size: 699 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z"/></svg>
|
||||
|
After Width: | Height: | Size: 559 B |
Binary file not shown.
|
After Width: | Height: | Size: 693 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 693 KiB |
Reference in New Issue
Block a user