2026-04-25 20:43:37 +12:00
< script lang = "ts" >
import { invalidateAll } from '$app/navigation' ;
import { api } from '$lib/api' ;
2026-05-08 23:07:01 +12:00
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte' ;
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte' ;
2026-04-25 22:51:36 +12:00
import { clientSession } from '$lib/session' ;
2026-05-08 09:06:14 +12:00
import { toast } from '$lib/toast' ;
import { BarChart3 , CirclePlus , Wheat } from 'lucide-svelte' ;
import type { ComponentType } from 'svelte' ;
2026-04-25 22:51:36 +12:00
import type { Mix , Product , ProductCostBreakdown , RawMaterial } from '$lib/types' ;
2026-04-25 20:43:37 +12:00
let { data } = $props ();
let isCreating = $state ( false );
let pendingMaterialId = $state < number | null >( null );
let successMessage = $state ( '' );
let errorMessage = $state ( '' );
2026-05-08 09:06:14 +12:00
type RawMaterialsView = 'overview' | 'create' | 'library' ;
type RailItem = {
id : RawMaterialsView ;
label : string ;
description : string ;
icon : ComponentType ;
group : string ;
};
const railItems : RailItem [] = [
{
id : 'overview' ,
label : 'Overview' ,
description : 'Pricing health, downstream exposure, and current portfolio snapshot.' ,
icon : BarChart3 ,
group : 'Workspace'
},
{
id : 'create' ,
label : 'Add Material' ,
description : 'Create a new raw material and seed its first active price version.' ,
icon : CirclePlus ,
group : 'Workspace'
},
{
id : 'library' ,
label : 'Material Library' ,
description : 'Review live materials, price versions, and downstream impact.' ,
icon : Wheat ,
group : 'Workspace'
}
];
2026-05-08 23:07:01 +12:00
const railGroups = [... new Set ( railItems . map (( item ) => item . group ))]. map (( group ) => ({
label : group ,
items : railItems.filter (( item ) => item . group === group )
}));
2026-05-08 09:06:14 +12:00
let activeView = $state < RawMaterialsView >( 'overview' );
const pageSize = 20 ;
let overviewMixesPage = $state ( 1 );
let overviewProductsPage = $state ( 1 );
let materialLibraryPage = $state ( 1 );
2026-04-25 20:43:37 +12:00
const today = new Date (). toISOString (). slice ( 0 , 10 );
function currency ( value : number | null | undefined , digits = 2 ) {
if ( value === null || value === undefined ) {
return 'N/A' ;
}
return `$ ${ value . toFixed ( digits ) } ` ;
}
2026-04-25 22:51:36 +12:00
function formatDate ( value : string | null | undefined ) {
if ( ! value ) {
return 'No date' ;
}
return new Intl . DateTimeFormat ( 'en-NZ' , {
day : 'numeric' ,
month : 'short' ,
year : 'numeric'
}). format ( new Date ( value ));
}
2026-04-25 20:43:37 +12:00
function getImpactedMixes ( materialId : number ) : Mix [] {
return data . mixes . filter (( mix : Mix ) => mix . ingredients . some (( ingredient ) => ingredient . raw_material_id === materialId ));
}
function getImpactedProducts ( materialId : number ) : Array < Product & { deliveredCost : ProductCostBreakdown | undefined }> {
const mixIds = new Set ( getImpactedMixes ( materialId ). map (( mix ) => mix . id ));
return data . products
. filter (( product : Product ) => mixIds . has ( product . mix_id ?? - 1 ))
. map (( product : Product ) => ({
... product ,
deliveredCost : data.productCosts.find (( row : ProductCostBreakdown ) => row . product_id === product . id )
}));
}
2026-05-08 09:06:14 +12:00
function totalPages ( totalItems : number ) {
return Math . max ( 1 , Math . ceil ( totalItems / pageSize ));
}
function clampPage ( page : number , totalItems : number ) {
return Math . min ( Math . max ( 1 , page ), totalPages ( totalItems ));
}
function paginate < T >( items : T [], page : number ) {
const safePage = clampPage ( page , items . length );
const start = ( safePage - 1 ) * pageSize ;
return items . slice ( start , start + pageSize );
}
2026-04-25 20:43:37 +12:00
async function handleCreateMaterial ( event : SubmitEvent ) {
event . preventDefault ();
isCreating = true ;
2026-05-08 09:06:14 +12:00
const tid = toast . loading ( 'Creating raw material…' );
2026-04-25 20:43:37 +12:00
const form = event . currentTarget as HTMLFormElement ;
const formData = new FormData ( form );
try {
await api . createRawMaterial ({
name : String ( formData . get ( 'name' ) ?? '' ). trim (),
supplier : String ( formData . get ( 'supplier' ) ?? '' ). trim () || null ,
unit_of_measure : String ( formData . get ( 'unit_of_measure' ) ?? '' ). trim (),
kg_per_unit : Number ( formData . get ( 'kg_per_unit' )),
status : String ( formData . get ( 'status' ) ?? 'active' ),
notes : String ( formData . get ( 'notes' ) ?? '' ). trim () || null ,
initial_price : {
market_value : Number ( formData . get ( 'market_value' )),
waste_percentage : Number ( formData . get ( 'waste_percentage' )),
effective_date : String ( formData . get ( 'effective_date' ) ?? today ),
status : 'active' ,
notes : String ( formData . get ( 'price_notes' ) ?? '' ). trim () || null
}
});
form . reset ();
const effectiveDate = form . elements . namedItem ( 'effective_date' );
if ( effectiveDate instanceof HTMLInputElement ) {
effectiveDate . value = today ;
}
2026-05-08 09:06:14 +12:00
toast . dismiss ( tid );
toast . success ( 'Raw material created' );
2026-04-25 20:43:37 +12:00
await invalidateAll ();
} catch ( error ) {
2026-05-08 09:06:14 +12:00
toast . dismiss ( tid );
toast . error ( error instanceof Error ? error . message : 'Unable to create raw material' );
2026-04-25 20:43:37 +12:00
} finally {
isCreating = false ;
}
}
async function handleAddPrice ( event : SubmitEvent , rawMaterialId : number ) {
event . preventDefault ();
pendingMaterialId = rawMaterialId ;
2026-05-08 09:06:14 +12:00
const tid = toast . loading ( 'Saving price…' );
2026-04-25 20:43:37 +12:00
const form = event . currentTarget as HTMLFormElement ;
const formData = new FormData ( form );
try {
await api . addRawMaterialPrice ( rawMaterialId , {
market_value : Number ( formData . get ( 'market_value' )),
waste_percentage : Number ( formData . get ( 'waste_percentage' )),
effective_date : String ( formData . get ( 'effective_date' ) ?? today ),
status : 'active' ,
notes : String ( formData . get ( 'notes' ) ?? '' ). trim () || null
});
form . reset ();
const effectiveDate = form . elements . namedItem ( 'effective_date' );
if ( effectiveDate instanceof HTMLInputElement ) {
effectiveDate . value = today ;
}
2026-05-08 09:06:14 +12:00
toast . dismiss ( tid );
toast . success ( 'Price version saved' );
2026-04-25 20:43:37 +12:00
await invalidateAll ();
} catch ( error ) {
2026-05-08 09:06:14 +12:00
toast . dismiss ( tid );
toast . error ( error instanceof Error ? error . message : 'Unable to add price version' );
2026-04-25 20:43:37 +12:00
} finally {
pendingMaterialId = null ;
}
}
2026-04-25 22:51:36 +12:00
const totalSpend = $derived (
data . rawMaterials . reduce (
( sum : number , material : RawMaterial ) => sum + ( material . current_price ? . market_value ?? 0 ),
0
)
);
const averageWaste = $derived (
data . rawMaterials . length
? data . rawMaterials . reduce (
( sum : number , material : RawMaterial ) => sum + ( material . current_price ? . waste_percentage ?? 0 ),
0
) / data.rawMaterials.length
: 0
);
const latestEffectiveDate = $derived (
[... data . rawMaterials ]
. map (( material : RawMaterial ) => material . current_price ? . effective_date )
. filter ( Boolean )
. sort ()
. at ( - 1 ) ?? null
);
const activeMaterials = $derived ( data . rawMaterials . filter (( material : RawMaterial ) => material . status === 'active' ));
2026-05-08 09:06:14 +12:00
const activeRailItem = $derived ( railItems . find (( item ) => item . id === activeView ) ?? railItems [ 0 ]);
const pagedOverviewMixes = $derived ( paginate ( data . mixes , overviewMixesPage ));
const pagedOverviewProducts = $derived ( paginate ( data . productCosts , overviewProductsPage ));
const pagedRawMaterials = $derived ( paginate ( data . rawMaterials , materialLibraryPage ));
$effect (() => {
overviewMixesPage = clampPage ( overviewMixesPage , data . mixes . length );
overviewProductsPage = clampPage ( overviewProductsPage , data . productCosts . length );
materialLibraryPage = clampPage ( materialLibraryPage , data . rawMaterials . length );
});
2026-04-25 20:43:37 +12:00
</ script >
2026-04-25 22:51:36 +12:00
{ #if ! $clientSession }
< section class = "locked-card" >
< p class = "eyebrow" > Client Access Required</ p >
< h2 > Sign in on the Hunter Premium Produce home page before viewing raw material pricing.</ h2 >
< p > This workflow updates source inputs and pushes new values through mix and product calculations.</ p >
< a href = "/" > Return to sign-in</ a >
2026-04-25 20:43:37 +12:00
</ section >
{ : else }
{ #if successMessage }
< p class = "feedback success" > { successMessage } </ p >
{ /if }
{ #if errorMessage }
< p class = "feedback error" > { errorMessage } </ p >
{ /if }
2026-05-08 23:07:01 +12:00
< AppSecondaryRailLayout >
{ # snippet rail ()}
< AppSecondaryRail
sectionLabel = "Raw Materials"
identityTitle = { ` ${ activeMaterials . length } active inputs` }
identitySubtitle= { ` ${ data . rawMaterials . length } tracked materials` }
identityIcon = { Wheat }
groups= { railGroups }
activeId = { activeView }
onSelect= {( id ) => ( activeView = id as RawMaterialsView )}
/>
{ /snippet }
2026-05-08 09:06:14 +12:00
< div class = "workspace-panel" >
{ #if activeRailItem }
{ @const PanelIcon = activeRailItem . icon }
< header class = "panel-header" >
< div class = "panel-header-icon" aria-hidden = "true" >
< PanelIcon size = { 16 } strokeWidth= { 1.75 } />
</ div >
2026-04-25 22:51:36 +12:00
< div >
2026-05-08 09:06:14 +12:00
< p class = "panel-eyebrow" > Workspace</ p >
< h2 > { activeRailItem . label } </ h2 >
< p class = "panel-description" > { activeRailItem . description } </ p >
2026-04-25 22:51:36 +12:00
</ div >
2026-05-08 09:06:14 +12:00
</ header >
{ /if }
< div class = "panel-body" >
{ #if activeView === 'overview' }
< section class = "metric-row" >
< article class = "metric-card" >
< span > Total Spend Tracked</ span >
< strong > { currency ( totalSpend )} </ strong >
< p > Across current market values</ p >
2026-04-25 22:51:36 +12:00
</ article >
2026-05-08 09:06:14 +12:00
< article class = "metric-card" >
< span > Average Waste</ span >
< strong > {( averageWaste * 100 ). toFixed ( 1 )} %</ strong >
< p > Current blended input loss</ p >
2026-04-25 22:51:36 +12:00
</ article >
2026-05-08 09:06:14 +12:00
< article class = "metric-card" >
< span > Latest Price Update</ span >
< strong > { formatDate ( latestEffectiveDate )} </ strong >
< p > Most recent effective date on file</ p >
2026-04-25 22:51:36 +12:00
</ article >
2026-04-25 20:43:37 +12:00
</ section >
2026-05-08 09:06:14 +12:00
< section class = "top-grid" >
< div class = "summary-stack" >
< article class = "surface-card" >
< div class = "section-heading" >
< div >
< p class = "eyebrow" > Downstream Snapshot</ p >
< h3 > Mixes affected by current inputs</ h3 >
</ div >
</ div >
< div class = "mini-list" >
{ #each pagedOverviewMixes as mix }
< article >
< div >
< strong > { mix . name } </ strong >
< span > { mix . client_name } </ span >
</ div >
< strong > { currency ( mix . mix_cost_per_kg , 4 )} / kg</ strong >
</ article >
{ /each }
</ div >
{ #if data . mixes . length > pageSize }
< div class = "pagination" >
< span class = "pagination-summary" > Showing { Math . min (( overviewMixesPage - 1 ) * pageSize + 1 , data . mixes . length )} -{ Math . min ( overviewMixesPage * pageSize , data . mixes . length )} of { data . mixes . length } </ span >
< div class = "pagination-actions" >
< button type = "button" class = "pagination-button" onclick = {() => ( overviewMixesPage -= 1 )} disabled= { overviewMixesPage === 1 } > Previous</ button >
< span class = "pagination-page" > Page { overviewMixesPage } of { totalPages ( data . mixes . length )} </ span >
< button type = "button" class = "pagination-button" onclick = {() => ( overviewMixesPage += 1 )} disabled= { overviewMixesPage >= totalPages ( data . mixes . length )} > Next</ button >
</ div >
</ div >
{ /if }
</ article >
< article class = "surface-card" >
< div class = "section-heading" >
< div >
< p class = "eyebrow" > Product Exposure</ p >
< h3 > Finished outputs linked to live pricing</ h3 >
</ div >
</ div >
< div class = "mini-list" >
{ #each pagedOverviewProducts as row }
< article >
< div >
< strong > { row . product_name } </ strong >
< span > { row . warnings . length ? 'Check warnings' : 'Stable pricing' } </ span >
</ div >
< strong > { currency ( row . finished_product_delivered )} </ strong >
</ article >
{ /each }
</ div >
{ #if data . productCosts . length > pageSize }
< div class = "pagination" >
< span class = "pagination-summary" > Showing { Math . min (( overviewProductsPage - 1 ) * pageSize + 1 , data . productCosts . length )} -{ Math . min ( overviewProductsPage * pageSize , data . productCosts . length )} of { data . productCosts . length } </ span >
< div class = "pagination-actions" >
< button type = "button" class = "pagination-button" onclick = {() => ( overviewProductsPage -= 1 )} disabled= { overviewProductsPage === 1 } > Previous</ button >
< span class = "pagination-page" > Page { overviewProductsPage } of { totalPages ( data . productCosts . length )} </ span >
< button type = "button" class = "pagination-button" onclick = {() => ( overviewProductsPage += 1 )} disabled= { overviewProductsPage >= totalPages ( data . productCosts . length )} > Next</ button >
</ div >
</ div >
{ /if }
</ article >
2026-04-25 20:43:37 +12:00
</ div >
2026-05-08 09:06:14 +12:00
</ section >
2026-04-25 20:43:37 +12:00
2026-05-08 09:06:14 +12:00
{ :else if activeView === 'create' }
< section class = "top-grid create-grid" >
< article class = "surface-card form-card" >
< div class = "section-heading" >
< div >
< p class = "eyebrow" > Create Input</ p >
< h3 > Add a new raw material</ h3 >
</ div >
< span class = "soft-pill" > Live costing source</ span >
</ div >
2026-04-25 20:43:37 +12:00
2026-05-08 09:06:14 +12:00
< form class = "material-form" onsubmit = { handleCreateMaterial } >
< div class = "form-grid" >
< label >
Name
< input name = "name" required />
</ label >
< label >
Supplier
< input name = "supplier" />
</ label >
< label >
Unit of measure
< input name = "unit_of_measure" value = "tonne" required />
</ label >
< label >
Kg per unit
< input name = "kg_per_unit" type = "number" min = "0.0001" step = "0.0001" value = "1000" required />
</ label >
< label >
Market value
< input name = "market_value" type = "number" min = "0.0001" step = "0.0001" required />
</ label >
< label >
Waste percentage
< input name = "waste_percentage" type = "number" min = "0" max = "1" step = "0.0001" value = "0" required />
</ label >
< label >
Effective date
< input name = "effective_date" type = "date" value = { today } required />
</ label >
< label >
Status
< select name = "status" >
< option value = "active" > Active</ option >
< option value = "draft" > Draft</ option >
< option value = "inactive" > Inactive</ option >
</ select >
</ label >
</ div >
< div class = "form-grid single" >
< label >
Material notes
< textarea name = "notes" rows = "3" ></ textarea >
</ label >
< label >
Price notes
< textarea name = "price_notes" rows = "3" ></ textarea >
</ label >
</ div >
< button class = "primary-button" type = "submit" disabled = { isCreating } >
{ isCreating ? 'Creating material...' : 'Create raw material' }
</ button >
</ form >
</ article >
2026-04-25 20:43:37 +12:00
2026-05-08 09:06:14 +12:00
< div class = "summary-stack" >
< article class = "surface-card mini-metric-card" >
< div class = "section-heading" >
< div >
< p class = "eyebrow" > Portfolio Health</ p >
< h3 > Current input coverage</ h3 >
</ div >
</ div >
2026-04-25 22:51:36 +12:00
2026-05-08 09:06:14 +12:00
< div class = "mini-list" >
2026-04-25 22:51:36 +12:00
< article >
< div >
2026-05-08 09:06:14 +12:00
< strong > Active materials</ strong >
< span > Ready for live calculations</ span >
2026-04-25 22:51:36 +12:00
</ div >
2026-05-08 09:06:14 +12:00
< strong > { activeMaterials . length } </ strong >
2026-04-25 22:51:36 +12:00
</ article >
2026-05-08 09:06:14 +12:00
< article >
< div >
< strong > Total tracked</ strong >
< span > Across all statuses</ span >
</ div >
< strong > { data . rawMaterials . length } </ strong >
</ article >
< article >
< div >
< strong > Latest effective date</ strong >
< span > Most recent seeded version</ span >
</ div >
< strong > { formatDate ( latestEffectiveDate )} </ strong >
</ article >
</ div >
</ article >
</ div >
2026-04-25 20:43:37 +12:00
</ section >
2026-05-08 09:06:14 +12:00
{ :else if activeView === 'library' }
< section class = "materials-list" >
{ #each pagedRawMaterials as material }
{ @const impactedMixes = getImpactedMixes ( material . id )}
{ @const impactedProducts = getImpactedProducts ( material . id )}
2026-04-25 22:51:36 +12:00
2026-05-08 09:06:14 +12:00
< article class = "surface-card material-card" >
< div class = "material-header" >
< div class = "material-title" >
< span class = { `material-icon ${ material . status === 'active' ? 'active' : 'muted' } ` } > RM</span >
2026-04-25 22:51:36 +12:00
< div >
2026-05-08 09:06:14 +12:00
< h3 > { material . name } </ h3 >
< p > { material . supplier || 'Supplier not set' } · { material . unit_of_measure } · { material . kg_per_unit } kg per unit</ p >
2026-04-25 22:51:36 +12:00
</ div >
2026-05-08 09:06:14 +12:00
</ div >
< span class = { `status-pill ${ material . status === 'active' ? 'positive' : 'neutral' } ` } > { material . status } </span >
</ div >
< div class = "material-grid" >
< section class = "stats-grid" >
< article >
< span > Market value</ span >
< strong > { currency ( material . current_price ? . market_value )} </ strong >
</ article >
< article >
< span > Waste</ span >
< strong >
{ material . current_price ? `$ {( material . current_price . waste_percentage * 100 ). toFixed ( 1 )} % ` : 'N/A' }
</ strong >
</ article >
< article >
< span > Cost per kg</ span >
< strong > { currency ( material . current_price ? . cost_per_kg , 4 )} </ strong >
</ article >
< article >
< span > Effective date</ span >
< strong > { formatDate ( material . current_price ? . effective_date )} </ strong >
</ article >
</ section >
< form class = "price-card" onsubmit = {( event ) => handleAddPrice ( event , material . id )} >
< div class = "section-heading" >
< div >
< p class = "eyebrow" > New Version</ p >
< h4 > Record a fresh price</ h4 >
</ div >
</ div >
< div class = "form-grid compact" >
< label >
Market value
< input name = "market_value" type = "number" min = "0.0001" step = "0.0001" required />
</ label >
< label >
Waste percentage
< input name = "waste_percentage" type = "number" min = "0" max = "1" step = "0.0001" value = "0" required />
</ label >
< label >
Effective date
< input name = "effective_date" type = "date" value = { today } required />
</ label >
</ div >
< label >
Notes
< textarea name = "notes" rows = "2" ></ textarea >
</ label >
< button class = "primary-button" type = "submit" disabled = { pendingMaterialId === material . id } >
{ pendingMaterialId === material . id ? 'Saving price...' : 'Save price version' }
</ button >
</ form >
</ div >
< div class = "impact-grid" >
< section class = "impact-card" >
< div class = "impact-heading" >
< h4 > Impacted mixes</ h4 >
< span > { impactedMixes . length } </ span >
</ div >
{ #if impactedMixes . length }
< div class = "impact-list" >
{ #each impactedMixes as mix }
< article >
< div >
< strong > { mix . name } </ strong >
< span > { mix . client_name } </ span >
</ div >
< strong > { currency ( mix . mix_cost_per_kg , 4 )} / kg</ strong >
</ article >
{ /each }
</ div >
{ : else }
< p class = "empty" > No active mix currently references this material.</ p >
{ /if }
</ section >
< section class = "impact-card" >
< div class = "impact-heading" >
< h4 > Impacted products</ h4 >
< span > { impactedProducts . length } </ span >
</ div >
{ #if impactedProducts . length }
< div class = "impact-list" >
{ #each impactedProducts as product }
< article >
< div >
< strong > { product . name } </ strong >
< span > { product . mix_name } </ span >
</ div >
< strong > { currency ( product . deliveredCost ? . finished_product_delivered )} </ strong >
</ article >
{ /each }
</ div >
{ : else }
< p class = "empty" > No finished product currently depends on this material.</ p >
{ /if }
</ section >
</ div >
</ article >
{ /each }
{ #if data . rawMaterials . length > pageSize }
< div class = "pagination surface-card library-pagination" >
< span class = "pagination-summary" > Showing { Math . min (( materialLibraryPage - 1 ) * pageSize + 1 , data . rawMaterials . length )} -{ Math . min ( materialLibraryPage * pageSize , data . rawMaterials . length )} of { data . rawMaterials . length } </ span >
< div class = "pagination-actions" >
< button type = "button" class = "pagination-button" onclick = {() => ( materialLibraryPage -= 1 )} disabled= { materialLibraryPage === 1 } > Previous</ button >
< span class = "pagination-page" > Page { materialLibraryPage } of { totalPages ( data . rawMaterials . length )} </ span >
< button type = "button" class = "pagination-button" onclick = {() => ( materialLibraryPage += 1 )} disabled= { materialLibraryPage >= totalPages ( data . rawMaterials . length )} > Next</ button >
</ div >
2026-04-25 22:51:36 +12:00
</ div >
2026-04-25 20:43:37 +12:00
{ /if }
</ section >
2026-05-08 09:06:14 +12:00
{ /if }
</ div >
</ div >
2026-05-08 23:07:01 +12:00
</ AppSecondaryRailLayout >
2026-04-25 20:43:37 +12:00
{ /if }
< style >
h2 ,
h3 ,
2026-04-25 22:51:36 +12:00
h4 ,
2026-04-25 20:43:37 +12:00
p {
margin : 0 ;
}
. eyebrow {
2026-04-25 22:51:36 +12:00
color : #7f8e85 ;
2026-04-25 20:43:37 +12:00
font-size : 0.78 rem ;
2026-04-25 22:51:36 +12:00
font-weight : 600 ;
letter-spacing : 0.08 em ;
2026-04-25 20:43:37 +12:00
text-transform : uppercase ;
}
2026-04-25 22:51:36 +12:00
. locked-card ,
. feedback ,
. metric-card ,
. surface-card {
background : var ( -- panel );
2026-04-25 20:43:37 +12:00
border : 1 px solid var ( -- line );
2026-04-25 22:51:36 +12:00
border-radius : 1.35 rem ;
2026-04-25 20:43:37 +12:00
box-shadow : var ( -- shadow );
}
2026-04-25 22:51:36 +12:00
. locked-card ,
. feedback ,
2026-05-08 23:07:01 +12:00
: global ( . secondary-rail-layout ) {
2026-04-25 22:51:36 +12:00
margin-bottom : 1.25 rem ;
}
. locked-card ,
2026-04-25 20:43:37 +12:00
. surface-card {
2026-04-25 22:51:36 +12:00
padding : 1.2 rem ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. locked-card {
2026-04-25 20:43:37 +12:00
display : grid ;
2026-04-25 22:51:36 +12:00
gap : 0.7 rem ;
2026-04-25 20:43:37 +12:00
max-width : 42 rem ;
}
2026-04-25 22:51:36 +12:00
. locked-card h2 ,
2026-05-08 09:06:14 +12:00
. panel-header h2 {
2026-04-25 22:51:36 +12:00
margin : 0.35 rem 0 0.45 rem ;
font-size : clamp ( 1.7 rem , 3 vw , 2.25 rem );
font-weight : 700 ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. locked-card p : last-of-type ,
. metric-card p ,
. mini-list span ,
. material-title p ,
. stats-grid span ,
. impact-list span ,
. empty {
2026-04-25 20:43:37 +12:00
color : var ( -- muted );
}
2026-04-25 22:51:36 +12:00
. locked-card a {
color : var ( -- green - deep );
font-weight : 600 ;
2026-04-25 20:43:37 +12:00
}
. feedback {
2026-04-25 22:51:36 +12:00
padding : 0.95 rem 1 rem ;
2026-04-25 20:43:37 +12:00
font-weight : 600 ;
}
. feedback . success {
2026-04-25 22:51:36 +12:00
color : var ( -- green - deep );
border-color : #d8ecdf ;
background : #f6fcf8 ;
2026-04-25 20:43:37 +12:00
}
. feedback . error {
2026-04-25 22:51:36 +12:00
color : #a03737 ;
border-color : #f0d9d9 ;
background : #fff8f8 ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. metric-row ,
2026-04-25 20:43:37 +12:00
. top-grid ,
. material-grid ,
2026-05-08 23:07:01 +12:00
. impact-grid {
2026-04-25 20:43:37 +12:00
display : grid ;
gap : 1 rem ;
}
2026-05-08 09:06:14 +12:00
. workspace-panel {
display : flex ;
flex-direction : column ;
min-width : 0 ;
background : var ( -- panel );
}
. panel-header {
2026-05-08 23:07:01 +12:00
position : sticky ;
top : 0 ;
z-index : 2 ;
2026-05-08 09:06:14 +12:00
display : flex ;
align-items : flex-start ;
gap : 1 rem ;
padding : 1.25 rem 1.5 rem ;
border-bottom : 1 px solid var ( -- line );
background : var ( -- panel - soft );
}
. panel-header-icon {
display : flex ;
align-items : center ;
justify-content : center ;
flex-shrink : 0 ;
width : 2.4 rem ;
height : 2.4 rem ;
border-radius : 0.72 rem ;
background : var ( -- color - brand - tint );
color : var ( -- color - brand );
border : 1 px solid color-mix ( in srgb , var ( -- color - brand ) 15 % , transparent );
margin-top : 0.15 rem ;
}
. panel-eyebrow {
margin : 0 ;
font-size : 0.7 rem ;
font-weight : 700 ;
letter-spacing : 0.1 em ;
text-transform : uppercase ;
color : var ( -- muted );
}
. panel-description {
margin : 0 ;
font-size : 0.84 rem ;
color : var ( -- muted );
line-height : 1.5 ;
}
. panel-body {
padding : 1.5 rem ;
}
2026-04-25 22:51:36 +12:00
. metric-row {
grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr ));
}
. metric-card {
padding : 1.15 rem 1.2 rem ;
}
. metric-card span {
display : block ;
color : var ( -- muted );
font-size : 0.9 rem ;
}
. metric-card strong {
display : block ;
margin : 0.55 rem 0 0.3 rem ;
font-size : 1.9 rem ;
font-weight : 700 ;
}
2026-04-25 20:43:37 +12:00
. top-grid {
2026-04-25 22:51:36 +12:00
grid-template-columns : minmax ( 0 , 1.2 fr ) minmax ( 320 px , 0.85 fr );
2026-04-25 20:43:37 +12:00
}
2026-05-08 09:06:14 +12:00
. create-grid {
grid-template-columns : minmax ( 0 , 1.2 fr ) minmax ( 280 px , 0.65 fr );
}
2026-04-25 22:51:36 +12:00
. summary-stack {
display : grid ;
gap : 1 rem ;
}
. section-heading ,
. material-header ,
. impact-heading ,
. mini-list article ,
. impact-list article {
2026-04-25 20:43:37 +12:00
display : flex ;
2026-04-25 22:51:36 +12:00
align-items : flex-start ;
2026-04-25 20:43:37 +12:00
justify-content : space-between ;
2026-04-25 22:51:36 +12:00
gap : 0.75 rem ;
}
. section-heading {
2026-04-25 20:43:37 +12:00
margin-bottom : 1 rem ;
}
2026-04-25 22:51:36 +12:00
. section-heading h3 ,
. material-header h3 ,
. impact-heading h4 {
font-size : 1.12 rem ;
font-weight : 700 ;
}
. soft-pill {
padding : 0.48 rem 0.8 rem ;
border-radius : 999 px ;
color : var ( -- green - deep );
background : var ( -- green - soft );
font-size : 0.86 rem ;
font-weight : 600 ;
}
2026-04-25 20:43:37 +12:00
. material-form ,
2026-04-25 22:51:36 +12:00
. price-card {
2026-04-25 20:43:37 +12:00
display : grid ;
gap : 1 rem ;
}
. form-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
2026-04-25 22:51:36 +12:00
gap : 0.85 rem ;
}
. form-grid . single {
grid-template-columns : 1 fr ;
2026-04-25 20:43:37 +12:00
}
. form-grid . compact {
grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr ));
}
label {
display : grid ;
gap : 0.35 rem ;
2026-04-25 22:51:36 +12:00
color : #53645b ;
font-size : 0.9 rem ;
2026-04-25 20:43:37 +12:00
font-weight : 600 ;
}
input ,
textarea ,
select {
width : 100 % ;
2026-04-25 22:51:36 +12:00
padding : 0.9 rem 0.95 rem ;
border : 1 px solid var ( -- line - strong );
border-radius : 0.95 rem ;
background : var ( -- panel - soft );
color : var ( -- text );
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. primary-button {
justify-self : start ;
padding : 0.85 rem 1 rem ;
2026-04-25 20:43:37 +12:00
border : none ;
2026-04-25 22:51:36 +12:00
border-radius : 0.9 rem ;
color : #fff ;
2026-05-08 09:06:14 +12:00
background : var ( -- color - brand );
box-shadow : none ;
2026-04-25 22:51:36 +12:00
font-weight : 600 ;
2026-04-25 20:43:37 +12:00
cursor : pointer ;
}
2026-04-25 22:51:36 +12:00
. primary-button : disabled {
2026-04-25 20:43:37 +12:00
opacity : 0.7 ;
cursor : wait ;
}
2026-04-25 22:51:36 +12:00
. mini-list ,
. impact-list ,
. materials-list {
2026-04-25 20:43:37 +12:00
display : grid ;
2026-04-25 22:51:36 +12:00
gap : 0.8 rem ;
2026-04-25 20:43:37 +12:00
}
2026-05-08 09:06:14 +12:00
. pagination {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 0.9 rem ;
margin-top : 1 rem ;
padding-top : 0.95 rem ;
border-top : 1 px solid var ( -- line );
}
. pagination-summary ,
. pagination-page {
color : var ( -- muted );
font-size : 0.82 rem ;
font-weight : 600 ;
}
. pagination-actions {
display : flex ;
align-items : center ;
gap : 0.6 rem ;
}
. pagination-button {
padding : 0.58 rem 0.82 rem ;
border : 1 px solid var ( -- line );
border-radius : 0.75 rem ;
background : var ( -- panel - soft );
color : var ( -- text );
font-size : 0.82 rem ;
font-weight : 600 ;
cursor : pointer ;
}
. pagination-button : disabled {
opacity : 0.45 ;
cursor : not-allowed ;
}
. library-pagination {
padding : 1 rem 1.2 rem ;
}
2026-04-25 22:51:36 +12:00
. mini-list article ,
. impact-list article {
padding : 0.95 rem 1 rem ;
border : 1 px solid var ( -- line );
2026-04-25 20:43:37 +12:00
border-radius : 1 rem ;
2026-04-25 22:51:36 +12:00
background : var ( -- panel - soft );
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. material-card {
2026-04-25 20:43:37 +12:00
display : grid ;
2026-04-25 22:51:36 +12:00
gap : 1.1 rem ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. material-title {
2026-04-25 20:43:37 +12:00
display : flex ;
align-items : center ;
2026-04-25 22:51:36 +12:00
gap : 0.85 rem ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. material-icon {
width : 2.4 rem ;
height : 2.4 rem ;
display : inline-flex ;
align-items : center ;
justify-content : center ;
border-radius : 0.85 rem ;
font-size : 0.76 rem ;
font-weight : 700 ;
letter-spacing : 0.05 em ;
flex-shrink : 0 ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. material-icon . active {
color : #fff ;
2026-05-08 09:06:14 +12:00
background : var ( -- color - brand );
2026-04-25 22:51:36 +12:00
}
. material-icon . muted {
color : #55685f ;
background : #e9efeb ;
2026-04-25 20:43:37 +12:00
}
. status-pill {
2026-04-25 22:51:36 +12:00
display : inline-flex ;
align-items : center ;
justify-content : center ;
padding : 0.42 rem 0.78 rem ;
2026-04-25 20:43:37 +12:00
border-radius : 999 px ;
2026-04-25 22:51:36 +12:00
font-size : 0.84 rem ;
font-weight : 600 ;
2026-04-25 20:43:37 +12:00
text-transform : capitalize ;
}
2026-04-25 22:51:36 +12:00
. status-pill . positive {
color : var ( -- green - deep );
background : var ( -- green - soft );
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. status-pill . neutral {
color : #5a6c63 ;
background : #edf2ef ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. material-grid {
grid-template-columns : minmax ( 0 , 0.9 fr ) minmax ( 0 , 1.1 fr );
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. stats-grid {
2026-04-25 20:43:37 +12:00
display : grid ;
2026-04-25 22:51:36 +12:00
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
gap : 0.8 rem ;
2026-04-25 20:43:37 +12:00
}
2026-04-25 22:51:36 +12:00
. stats-grid article ,
. price-card ,
. impact-card {
padding : 1 rem ;
border : 1 px solid var ( -- line );
border-radius : 1 rem ;
background : var ( -- panel - soft );
}
. stats-grid strong {
display : block ;
margin-top : 0.35 rem ;
font-size : 1.1 rem ;
font-weight : 700 ;
2026-04-25 20:43:37 +12:00
}
. impact-grid {
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
}
2026-04-25 22:51:36 +12:00
. impact-heading {
margin-bottom : 0.9 rem ;
}
. impact-heading span {
color : var ( -- muted );
font-size : 0.92 rem ;
font-weight : 600 ;
}
@ media ( max-width : 1180px ) {
2026-05-08 23:07:01 +12:00
: global ( . secondary-rail-layout ),
2026-04-25 22:51:36 +12:00
. metric-row ,
2026-04-25 20:43:37 +12:00
. top-grid ,
. material-grid ,
2026-04-25 22:51:36 +12:00
. impact-grid ,
. stats-grid {
2026-04-25 20:43:37 +12:00
grid-template-columns : 1 fr ;
}
}
2026-04-25 22:51:36 +12:00
@ media ( max-width : 820px ) {
2026-05-08 09:06:14 +12:00
. panel-header ,
2026-04-25 22:51:36 +12:00
. section-heading ,
. material-header ,
. impact-heading ,
. mini-list article ,
. impact-list article {
2026-04-25 20:43:37 +12:00
flex-direction : column ;
2026-04-25 22:51:36 +12:00
align-items : flex-start ;
2026-04-25 20:43:37 +12:00
}
. form-grid ,
. form-grid . compact {
grid-template-columns : 1 fr ;
}
2026-05-08 09:06:14 +12:00
. pagination ,
. pagination-actions {
flex-direction : column ;
align-items : flex-start ;
}
2026-04-25 20:43:37 +12:00
}
</ style >