1486 lines
34 KiB
Svelte
1486 lines
34 KiB
Svelte
<script lang="ts">
|
|
import { api } from '$lib/api';
|
|
import { clientSession, sessionHydrated } from '$lib/session';
|
|
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
|
|
|
|
type Segment = {
|
|
label: string;
|
|
value: number;
|
|
color: string;
|
|
};
|
|
|
|
type GaugeBar = {
|
|
x1: number;
|
|
y1: number;
|
|
x2: number;
|
|
y2: number;
|
|
color: string;
|
|
};
|
|
|
|
type WorkspaceFocus = {
|
|
code: string;
|
|
label: string;
|
|
detail: string;
|
|
value: string;
|
|
tone: 'positive' | 'warning' | 'neutral';
|
|
};
|
|
|
|
let { data } = $props();
|
|
let email = $state('operator@example.com');
|
|
let password = $state('changeme');
|
|
let isLoggingIn = $state(false);
|
|
let loginError = $state('');
|
|
|
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'];
|
|
|
|
async function handleLogin(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
loginError = '';
|
|
isLoggingIn = true;
|
|
|
|
try {
|
|
const session = await api.clientLogin(email, password);
|
|
clientSession.set(session);
|
|
} catch (error) {
|
|
loginError = error instanceof Error ? error.message : 'Unable to sign in';
|
|
} finally {
|
|
isLoggingIn = false;
|
|
}
|
|
}
|
|
|
|
function currency(value: number | null | undefined, digits = 2) {
|
|
if (value === null || value === undefined) {
|
|
return 'N/A';
|
|
}
|
|
|
|
return `$${value.toFixed(digits)}`;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
function findHighestProduct(products: ProductCostBreakdown[]) {
|
|
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
|
|
}
|
|
|
|
function findMostExpensiveMix(mixes: Mix[]) {
|
|
return [...mixes].sort((left, right) => (right.mix_cost_per_kg ?? 0) - (left.mix_cost_per_kg ?? 0))[0];
|
|
}
|
|
|
|
function findLatestMaterial(materials: RawMaterial[]) {
|
|
return [...materials].sort((left, right) => {
|
|
const leftDate = left.current_price?.effective_date ?? '';
|
|
const rightDate = right.current_price?.effective_date ?? '';
|
|
return rightDate.localeCompare(leftDate);
|
|
})[0];
|
|
}
|
|
|
|
function buildSegments() {
|
|
return [
|
|
{ label: 'Materials', value: data.rawMaterials.length, color: '#2c9b5f' },
|
|
{ label: 'Mixes', value: data.mixes.length, color: '#d7802a' },
|
|
{ label: 'Products', value: data.productCosts.length, color: '#286ea7' }
|
|
];
|
|
}
|
|
|
|
function polar(cx: number, cy: number, radius: number, angle: number) {
|
|
const radians = ((angle - 90) * Math.PI) / 180;
|
|
|
|
return {
|
|
x: cx + radius * Math.cos(radians),
|
|
y: cy + radius * Math.sin(radians)
|
|
};
|
|
}
|
|
|
|
function buildGaugeBars(segments: Segment[]) {
|
|
const total = segments.reduce((sum, segment) => sum + segment.value, 0) || 1;
|
|
const stops = segments.reduce<Array<{ threshold: number; color: string }>>((list, segment, index) => {
|
|
const previous = list[index - 1]?.threshold ?? 0;
|
|
list.push({
|
|
threshold: previous + segment.value / total,
|
|
color: segment.color
|
|
});
|
|
return list;
|
|
}, []);
|
|
|
|
return Array.from({ length: 24 }, (_, index) => {
|
|
const ratio = (index + 1) / 24;
|
|
const activeStop = stops.find((stop) => ratio <= stop.threshold) ?? stops[stops.length - 1];
|
|
const angle = -112 + index * (224 / 23);
|
|
const inner = polar(120, 126, 58, angle);
|
|
const outer = polar(120, 126, 95, angle);
|
|
|
|
return {
|
|
x1: Number(inner.x.toFixed(2)),
|
|
y1: Number(inner.y.toFixed(2)),
|
|
x2: Number(outer.x.toFixed(2)),
|
|
y2: Number(outer.y.toFixed(2)),
|
|
color: activeStop?.color ?? '#2c9b5f'
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildTrendSeries() {
|
|
const seeds = [
|
|
...data.rawMaterials.map((material: RawMaterial) => (material.current_price?.cost_per_kg ?? 0) * 780),
|
|
...data.mixes.map((mix: Mix) => (mix.mix_cost_per_kg ?? 0) * 640),
|
|
...data.productCosts.map((product: ProductCostBreakdown) => product.finished_product_delivered * 24)
|
|
].filter((value) => value > 0);
|
|
|
|
const source = seeds.length ? seeds : [320, 360, 420];
|
|
|
|
return monthLabels.map((_, index) => {
|
|
const base = source[index % source.length];
|
|
const swing = [0.86, 1.03, 0.78, 0.96, 1.18, 1.02, 1.12, 0.91, 1.14][index];
|
|
return Math.round(base * swing);
|
|
});
|
|
}
|
|
|
|
function chartPoints(values: number[]) {
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
|
|
return values.map((value, index) => {
|
|
const x = values.length === 1 ? 50 : (index * 100) / (values.length - 1);
|
|
const y = max === min ? 28 : 6 + ((max - value) / (max - min)) * 38;
|
|
|
|
return {
|
|
x: Number(x.toFixed(2)),
|
|
y: Number(y.toFixed(2)),
|
|
value
|
|
};
|
|
});
|
|
}
|
|
|
|
function linePath(values: number[]) {
|
|
return chartPoints(values)
|
|
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
|
.join(' ');
|
|
}
|
|
|
|
function areaPath(values: number[]) {
|
|
const points = chartPoints(values);
|
|
const head = points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ');
|
|
return `${head} L 100 52 L 0 52 Z`;
|
|
}
|
|
|
|
function focusMarker(values: number[]) {
|
|
const points = chartPoints(values);
|
|
const peak = points.reduce((best, point, index) => {
|
|
if (point.value > best.value) {
|
|
return { ...point, index };
|
|
}
|
|
|
|
return best;
|
|
}, { ...points[0], index: 0 });
|
|
|
|
return {
|
|
left: `${peak.x}%`,
|
|
label: `${peak.value}`,
|
|
month: monthLabels[peak.index] ?? monthLabels[0]
|
|
};
|
|
}
|
|
|
|
function buildFocusCards(): WorkspaceFocus[] {
|
|
const featuredMaterial = findLatestMaterial(data.rawMaterials);
|
|
const featuredMix = findMostExpensiveMix(data.mixes);
|
|
const featuredProduct = findHighestProduct(data.productCosts);
|
|
|
|
return [
|
|
{
|
|
code: 'RM',
|
|
label: featuredMaterial?.name ?? 'Raw material',
|
|
detail: `Updated ${formatDate(featuredMaterial?.current_price?.effective_date)}`,
|
|
value: currency(featuredMaterial?.current_price?.market_value),
|
|
tone: 'positive'
|
|
},
|
|
{
|
|
code: 'MX',
|
|
label: featuredMix?.name ?? 'Mix worksheet',
|
|
detail: `${featuredMix?.ingredients.length ?? 0} ingredients loaded`,
|
|
value: `${currency(featuredMix?.mix_cost_per_kg, 4)} / kg`,
|
|
tone: featuredMix?.warnings.length ? 'warning' : 'neutral'
|
|
},
|
|
{
|
|
code: 'PR',
|
|
label: featuredProduct?.product_name ?? 'Delivered product',
|
|
detail: featuredProduct?.warnings.length ? 'Warnings need review' : 'Pricing output is stable',
|
|
value: currency(featuredProduct?.finished_product_delivered),
|
|
tone: featuredProduct?.warnings.length ? 'warning' : 'positive'
|
|
}
|
|
];
|
|
}
|
|
|
|
const featuredProduct = $derived(findHighestProduct(data.productCosts));
|
|
const featuredMix = $derived(findMostExpensiveMix(data.mixes));
|
|
const featuredMaterial = $derived(findLatestMaterial(data.rawMaterials));
|
|
const productionSegments = $derived(buildSegments());
|
|
const gaugeBars = $derived(buildGaugeBars(productionSegments));
|
|
const totalTracked = $derived(
|
|
productionSegments.reduce((sum: number, segment: Segment) => sum + segment.value, 0)
|
|
);
|
|
const totalMarketValue = $derived(
|
|
data.rawMaterials.reduce(
|
|
(sum: number, material: RawMaterial) => sum + (material.current_price?.market_value ?? 0),
|
|
0
|
|
)
|
|
);
|
|
const averageMixCost = $derived(
|
|
data.mixes.length
|
|
? data.mixes.reduce((sum: number, mix: Mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
|
|
: 0
|
|
);
|
|
const trendSeries = $derived(buildTrendSeries());
|
|
const trendLine = $derived(linePath(trendSeries));
|
|
const trendArea = $derived(areaPath(trendSeries));
|
|
const trendFocus = $derived(focusMarker(trendSeries));
|
|
const topProducts = $derived(
|
|
[...data.productCosts]
|
|
.sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)
|
|
.slice(0, 4)
|
|
);
|
|
const focusCards = $derived(buildFocusCards());
|
|
</script>
|
|
|
|
{#if !$sessionHydrated}
|
|
<section class="dashboard-intro">
|
|
<div>
|
|
<p class="eyebrow">Client Workspace</p>
|
|
<h2>Restoring your workspace.</h2>
|
|
<p>Checking the saved client session before deciding whether sign-in is required.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="workspace-banner login-banner loading-banner">
|
|
<div>
|
|
<p class="eyebrow">Checking Session</p>
|
|
<h3>Hold while the app restores your client access state.</h3>
|
|
<p>The sign-in form only appears if no valid local session is available.</p>
|
|
</div>
|
|
</section>
|
|
{:else if !$clientSession}
|
|
<section class="dashboard-intro">
|
|
<div>
|
|
<!-- <p class="eyebrow">Client Workspace</p>-->
|
|
<h2>Welcome to the Hunter Premium Produce App</h2>
|
|
<p>Sign in to load input pricing, Mix Master products, and Scenario Builder.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="workspace-banner login-banner">
|
|
<div>
|
|
<p class="eyebrow">Client Sign-In</p>
|
|
<h3>Login to Hunter Premium Produce</h3>
|
|
<p>Enter your username & password to begin</p>
|
|
</div>
|
|
|
|
<form class="signin-form" onsubmit={handleLogin}>
|
|
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" />
|
|
<input bind:value={password} type="password" autocomplete="current-password" placeholder="Password" />
|
|
<button class="primary-button" type="submit" disabled={isLoggingIn}>
|
|
{isLoggingIn ? 'Signing in...' : 'Sign In'}
|
|
</button>
|
|
</form>
|
|
|
|
{#if loginError}
|
|
<p class="login-error">{loginError}</p>
|
|
{/if}
|
|
</section>
|
|
{:else}
|
|
<section class="dashboard-intro">
|
|
<div>
|
|
<p class="eyebrow">Client Workspace</p>
|
|
<h2>Hunter Premium Produce costing overview.</h2>
|
|
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p>
|
|
</div>
|
|
|
|
<div class="intro-actions">
|
|
<button class="secondary-button" type="button">Apr, 2026</button>
|
|
<a class="primary-button" href="/products">Review Delivered Pricing</a>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="workspace-banner">
|
|
<div>
|
|
<p class="eyebrow">Account</p>
|
|
<h3>Hunter Premium Produce</h3>
|
|
<p>Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.</p>
|
|
</div>
|
|
|
|
<div class="focus-grid">
|
|
{#each focusCards as card}
|
|
<article class={`focus-card ${card.tone}`}>
|
|
<span class="focus-code">{card.code}</span>
|
|
<div>
|
|
<strong>{card.label}</strong>
|
|
<span>{card.detail}</span>
|
|
</div>
|
|
<em>{card.value}</em>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dashboard-grid">
|
|
<article class="panel-card market-card">
|
|
<div class="card-toolbar">
|
|
<span class="pill success">Latest market check</span>
|
|
<div class="toggle-pill">
|
|
<span class="active">NZD</span>
|
|
<span>USD</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="market-layout">
|
|
<div>
|
|
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
|
|
<p>{formatDate(featuredMaterial?.current_price?.effective_date)}</p>
|
|
<div class="hero-value">{currency(featuredMaterial?.current_price?.market_value)}</div>
|
|
<p class="support-text">
|
|
{currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg
|
|
<span>Current blend for Hunter Premium Produce</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field-emblem" aria-hidden="true">
|
|
<span class="sun-core"></span>
|
|
<span class="field-stripe one"></span>
|
|
<span class="field-stripe two"></span>
|
|
<span class="field-stripe three"></span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel-card gauge-card">
|
|
<div class="card-toolbar">
|
|
<div>
|
|
<h3>Tracked Workspace</h3>
|
|
<p>Entities currently feeding the Hunter costing model</p>
|
|
</div>
|
|
<button class="secondary-button compact" type="button">Snapshot</button>
|
|
</div>
|
|
|
|
<div class="gauge-visual">
|
|
<svg viewBox="0 0 240 150" aria-hidden="true">
|
|
{#each gaugeBars as bar}
|
|
<line
|
|
x1={bar.x1}
|
|
y1={bar.y1}
|
|
x2={bar.x2}
|
|
y2={bar.y2}
|
|
stroke={bar.color}
|
|
stroke-width="11"
|
|
stroke-linecap="round"
|
|
/>
|
|
{/each}
|
|
</svg>
|
|
|
|
<div class="gauge-center">
|
|
<strong>{totalTracked}</strong>
|
|
<span>tracked items</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="legend-row">
|
|
{#each productionSegments as segment}
|
|
<span><i style={`background:${segment.color};`}></i>{segment.label} {segment.value}</span>
|
|
{/each}
|
|
</div>
|
|
</article>
|
|
|
|
<div class="metric-stack">
|
|
<article class="panel-card metric-card">
|
|
<div class="metric-head">
|
|
<span>Total Input Spend</span>
|
|
<span class="metric-icon"></span>
|
|
</div>
|
|
<strong>{currency(totalMarketValue)}</strong>
|
|
<p>Across all tracked raw materials</p>
|
|
</article>
|
|
|
|
<article class="panel-card metric-card">
|
|
<div class="metric-head">
|
|
<span>Average Mix Cost</span>
|
|
<span class="metric-icon"></span>
|
|
</div>
|
|
<strong>{currency(averageMixCost, 4)}</strong>
|
|
<p>Per kg across the current mix set</p>
|
|
</article>
|
|
|
|
<article class="panel-card metric-card">
|
|
<div class="metric-head">
|
|
<span>Top Delivered Output</span>
|
|
<span class="metric-icon"></span>
|
|
</div>
|
|
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
|
|
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="analysis-grid">
|
|
<article class="panel-card chart-card">
|
|
<div class="card-toolbar">
|
|
<div>
|
|
<h3>Monthly Pricing Pulse</h3>
|
|
<p>Trend view of input pressure and delivered output movement</p>
|
|
</div>
|
|
|
|
<div class="chart-actions">
|
|
<button class="secondary-button compact" type="button">Mixes</button>
|
|
<button class="secondary-button compact" type="button">2026</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-shell">
|
|
<div class="focus-badge" style={`left:${trendFocus.left};`}>
|
|
<span>Peak {trendFocus.month}</span>
|
|
<strong>{trendFocus.label}</strong>
|
|
</div>
|
|
|
|
<svg viewBox="0 0 100 56" preserveAspectRatio="none" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#59c97f" stop-opacity="0.32" />
|
|
<stop offset="100%" stop-color="#59c97f" stop-opacity="0.02" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<path d={trendArea} fill="url(#chart-fill)"></path>
|
|
<path d={trendLine} fill="none" stroke="#2ba560" stroke-width="1.6" stroke-linecap="round"></path>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="month-row">
|
|
{#each monthLabels as label}
|
|
<span>{label}</span>
|
|
{/each}
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel-card preview-card">
|
|
<div class="orchard-visual">
|
|
<div class="orchard-sky"></div>
|
|
<div class="orchard-sun"></div>
|
|
<div class="orchard-hill"></div>
|
|
<div class="orchard-row left"></div>
|
|
<div class="orchard-row center"></div>
|
|
<div class="orchard-row right"></div>
|
|
</div>
|
|
|
|
<div class="preview-body">
|
|
<div class="preview-header">
|
|
<div>
|
|
<h3>{featuredMix?.name ?? 'Mix Preview'}</h3>
|
|
<p>{featuredMix?.client_name ?? 'Hunter Premium Produce'}</p>
|
|
</div>
|
|
|
|
<a href="/mixes">Open Mix Master</a>
|
|
</div>
|
|
|
|
<div class="preview-facts">
|
|
<article>
|
|
<span>Ingredients</span>
|
|
<strong>{featuredMix?.ingredients.length ?? 0}</strong>
|
|
</article>
|
|
|
|
<article>
|
|
<span>Total Kg</span>
|
|
<strong>{featuredMix?.total_mix_kg ?? 0}</strong>
|
|
</article>
|
|
|
|
<article>
|
|
<span>Total Cost</span>
|
|
<strong>{currency(featuredMix?.total_mix_cost)}</strong>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="detail-grid">
|
|
<article class="panel-card task-card">
|
|
<div class="card-toolbar">
|
|
<div>
|
|
<h3>Priority Watchlist</h3>
|
|
<p>Current client-facing checkpoints generated from the active costing snapshot</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Focus</th>
|
|
<th>Owner</th>
|
|
<th>Reference Date</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each focusCards as card}
|
|
<tr>
|
|
<td class="task-cell" data-label="Focus">
|
|
<div class="table-item">
|
|
<span class={`task-icon ${card.tone}`}>{card.code}</span>
|
|
<div>
|
|
<strong>{card.label}</strong>
|
|
<span>{card.value}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td data-label="Owner">
|
|
<div class="owner-chip">
|
|
<span>HP</span>
|
|
<strong>Hunter Premium Produce</strong>
|
|
</div>
|
|
</td>
|
|
<td data-label="Reference Date">
|
|
<div class="due-block">
|
|
<strong>{card.detail}</strong>
|
|
<span>Current checkpoint</span>
|
|
</div>
|
|
</td>
|
|
<td data-label="Status">
|
|
<span class={`status-chip ${card.tone}`}>{card.tone === 'warning' ? 'Watch' : 'On track'}</span>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel-card summary-card">
|
|
<div class="card-toolbar">
|
|
<div>
|
|
<h3>Finished Product Summary</h3>
|
|
<p>Highest delivered pricing outputs</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="summary-list">
|
|
{#each topProducts as product}
|
|
<article>
|
|
<div class="summary-name">
|
|
<span class="summary-dot"></span>
|
|
<div>
|
|
<strong>{product.product_name}</strong>
|
|
<span>{product.warnings.length ? 'Review warnings' : 'Stable pricing output'}</span>
|
|
</div>
|
|
</div>
|
|
<strong>{currency(product.finished_product_delivered)}</strong>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
h2,
|
|
h3,
|
|
p {
|
|
margin: 0;
|
|
}
|
|
|
|
.eyebrow {
|
|
color: #85958c;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.dashboard-intro,
|
|
.workspace-banner,
|
|
.dashboard-grid,
|
|
.analysis-grid,
|
|
.detail-grid {
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.dashboard-intro,
|
|
.card-toolbar,
|
|
.metric-head,
|
|
.preview-header,
|
|
.intro-actions,
|
|
.chart-actions,
|
|
.workspace-banner {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.dashboard-intro,
|
|
.workspace-banner {
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.dashboard-intro h2,
|
|
.workspace-banner h3 {
|
|
margin: 0.3rem 0 0.35rem;
|
|
font-size: clamp(1.8rem, 3vw, 2.35rem);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.dashboard-intro p:last-child,
|
|
.workspace-banner p:last-child,
|
|
.card-toolbar p,
|
|
.metric-card p,
|
|
.preview-header p,
|
|
.support-text,
|
|
.summary-name span:last-child {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.primary-button,
|
|
.secondary-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 0.85rem;
|
|
padding: 0.85rem 1rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.primary-button {
|
|
border: none;
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
|
box-shadow: 0 8px 20px rgba(23, 75, 45, 0.2);
|
|
}
|
|
|
|
.secondary-button {
|
|
border: 1px solid var(--line-strong);
|
|
color: #304038;
|
|
background: #fff;
|
|
}
|
|
|
|
.secondary-button.compact {
|
|
padding: 0.65rem 0.85rem;
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.workspace-banner,
|
|
.panel-card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 1.4rem;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.workspace-banner,
|
|
.panel-card {
|
|
padding: 1.2rem;
|
|
}
|
|
|
|
.focus-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 0.85rem;
|
|
min-width: min(100%, 42rem);
|
|
}
|
|
|
|
.login-banner {
|
|
align-items: center;
|
|
}
|
|
|
|
.loading-banner {
|
|
min-height: 11rem;
|
|
}
|
|
|
|
.signin-form {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 0.75rem;
|
|
width: min(100%, 38rem);
|
|
}
|
|
|
|
.signin-form input {
|
|
width: 100%;
|
|
padding: 0.9rem 0.95rem;
|
|
border: 1px solid var(--line-strong);
|
|
border-radius: 0.95rem;
|
|
background: var(--panel-soft);
|
|
color: var(--text);
|
|
}
|
|
|
|
.login-error {
|
|
color: #a03737;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.focus-card {
|
|
display: grid;
|
|
gap: 0.4rem;
|
|
padding: 0.95rem 1rem;
|
|
border-radius: 1rem;
|
|
border: 1px solid var(--line);
|
|
background: var(--panel-soft);
|
|
}
|
|
|
|
.focus-card.positive {
|
|
background: linear-gradient(180deg, #f6fbf7 0%, #edf8f0 100%);
|
|
}
|
|
|
|
.focus-card.warning {
|
|
background: linear-gradient(180deg, #fffaf3 0%, #fff3e3 100%);
|
|
}
|
|
|
|
.focus-card.neutral {
|
|
background: linear-gradient(180deg, #f7faf8 0%, #eff4f1 100%);
|
|
}
|
|
|
|
.focus-code {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 0.75rem;
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
|
font-size: 0.74rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.focus-card strong,
|
|
.market-card h3,
|
|
.gauge-card h3,
|
|
.metric-card strong,
|
|
.summary-name strong,
|
|
.preview-facts strong {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.focus-card span {
|
|
color: var(--muted);
|
|
font-size: 0.84rem;
|
|
}
|
|
|
|
.focus-card em {
|
|
font-style: normal;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.dashboard-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.12fr) minmax(0, 1.02fr) minmax(16rem, 0.76fr);
|
|
grid-template-areas: 'market gauge metrics';
|
|
gap: 1rem;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.analysis-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.95fr);
|
|
gap: 1rem;
|
|
}
|
|
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.85fr);
|
|
gap: 1rem;
|
|
}
|
|
|
|
.market-card {
|
|
grid-area: market;
|
|
}
|
|
|
|
.gauge-card {
|
|
grid-area: gauge;
|
|
}
|
|
|
|
.card-toolbar {
|
|
align-items: flex-start;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.card-toolbar h3 {
|
|
font-size: 1.15rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.pill,
|
|
.toggle-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.pill {
|
|
padding: 0.48rem 0.8rem;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.pill.success {
|
|
color: var(--green-deep);
|
|
background: var(--green-soft);
|
|
}
|
|
|
|
.toggle-pill {
|
|
gap: 0.3rem;
|
|
padding: 0.25rem;
|
|
background: var(--panel-soft);
|
|
}
|
|
|
|
.toggle-pill span {
|
|
width: 2.2rem;
|
|
height: 1.8rem;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 999px;
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.toggle-pill .active {
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
|
}
|
|
|
|
.market-layout {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
min-height: 13rem;
|
|
}
|
|
|
|
.market-card h3 {
|
|
font-size: 2rem;
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
|
|
.market-card p {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.hero-value {
|
|
margin: 1.6rem 0 0.35rem;
|
|
font-size: 3rem;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
}
|
|
|
|
.support-text {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.6rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.field-emblem {
|
|
position: relative;
|
|
width: 8.5rem;
|
|
height: 8.5rem;
|
|
flex-shrink: 0;
|
|
border-radius: 1.4rem;
|
|
background: linear-gradient(180deg, #d7f0ff 0%, #fff0bf 45%, #89c762 46%, #3f8e3d 100%);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sun-core {
|
|
position: absolute;
|
|
top: 1.05rem;
|
|
right: 1rem;
|
|
width: 2.9rem;
|
|
height: 2.9rem;
|
|
border-radius: 999px;
|
|
background: radial-gradient(circle at 35% 35%, #fff8cc 0%, #ffd865 58%, #f4ae1f 100%);
|
|
}
|
|
|
|
.field-stripe {
|
|
position: absolute;
|
|
left: -10%;
|
|
right: -10%;
|
|
height: 22%;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.18);
|
|
transform: rotate(-18deg);
|
|
}
|
|
|
|
.field-stripe.one {
|
|
bottom: 2.4rem;
|
|
}
|
|
|
|
.field-stripe.two {
|
|
bottom: 1.35rem;
|
|
}
|
|
|
|
.field-stripe.three {
|
|
bottom: 0.3rem;
|
|
}
|
|
|
|
.gauge-visual {
|
|
position: relative;
|
|
height: 14rem;
|
|
}
|
|
|
|
.gauge-visual svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.gauge-center {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 2.2rem;
|
|
display: grid;
|
|
justify-items: center;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.gauge-center strong {
|
|
font-size: 2.15rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.gauge-center span,
|
|
.legend-row span {
|
|
color: var(--muted);
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.legend-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.legend-row span {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.legend-row i {
|
|
width: 0.55rem;
|
|
height: 0.55rem;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.metric-stack {
|
|
grid-area: metrics;
|
|
display: grid;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.metric-card {
|
|
min-height: 10rem;
|
|
}
|
|
|
|
.metric-head {
|
|
margin-bottom: 1rem;
|
|
color: var(--muted);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.metric-icon {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-radius: 999px;
|
|
background: linear-gradient(135deg, #eef8f1 0%, #dff5e8 100%);
|
|
border: 1px solid #d3eadb;
|
|
}
|
|
|
|
.metric-card strong {
|
|
display: block;
|
|
margin-bottom: 0.45rem;
|
|
font-size: 2rem;
|
|
}
|
|
|
|
.chart-shell {
|
|
position: relative;
|
|
height: 18rem;
|
|
margin-top: 0.4rem;
|
|
border-radius: 1.25rem;
|
|
background:
|
|
linear-gradient(180deg, rgba(88, 197, 121, 0.06) 0%, rgba(88, 197, 121, 0.01) 100%),
|
|
repeating-linear-gradient(
|
|
to bottom,
|
|
transparent 0 3.45rem,
|
|
rgba(196, 226, 205, 0.45) 3.45rem 3.55rem
|
|
);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.chart-shell svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.focus-badge {
|
|
position: absolute;
|
|
top: 0.9rem;
|
|
transform: translateX(-50%);
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
padding: 0.45rem 0.6rem;
|
|
border-radius: 0.8rem;
|
|
background: #1e2420;
|
|
color: #fff;
|
|
box-shadow: 0 10px 20px rgba(15, 23, 17, 0.16);
|
|
}
|
|
|
|
.focus-badge span {
|
|
font-size: 0.72rem;
|
|
opacity: 0.76;
|
|
}
|
|
|
|
.focus-badge strong {
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.month-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(9, minmax(0, 1fr));
|
|
gap: 0.4rem;
|
|
margin-top: 0.85rem;
|
|
color: #8b9a91;
|
|
font-size: 0.84rem;
|
|
}
|
|
|
|
.orchard-visual {
|
|
position: relative;
|
|
height: 16.5rem;
|
|
border-radius: 1.2rem;
|
|
overflow: hidden;
|
|
background: linear-gradient(180deg, #a7dafc 0%, #e5f4ff 42%, #6eb857 42%, #2d6d28 100%);
|
|
}
|
|
|
|
.orchard-sky,
|
|
.orchard-sun,
|
|
.orchard-hill,
|
|
.orchard-row {
|
|
position: absolute;
|
|
}
|
|
|
|
.orchard-sun {
|
|
top: 1.4rem;
|
|
right: 1.6rem;
|
|
width: 4.5rem;
|
|
height: 4.5rem;
|
|
border-radius: 999px;
|
|
background: radial-gradient(circle, #fff6c7 0%, #ffd567 58%, rgba(255, 213, 103, 0.18) 72%, transparent 73%);
|
|
}
|
|
|
|
.orchard-hill {
|
|
left: -8%;
|
|
right: -8%;
|
|
bottom: 30%;
|
|
height: 24%;
|
|
background: linear-gradient(180deg, rgba(66, 127, 50, 0.7), rgba(45, 94, 37, 0.95));
|
|
clip-path: polygon(0 75%, 18% 48%, 35% 61%, 56% 32%, 74% 54%, 100% 25%, 100% 100%, 0 100%);
|
|
}
|
|
|
|
.orchard-row {
|
|
bottom: -8%;
|
|
width: 38%;
|
|
height: 48%;
|
|
background: repeating-linear-gradient(
|
|
180deg,
|
|
rgba(60, 133, 43, 0.95) 0 14px,
|
|
rgba(47, 107, 34, 0.95) 14px 28px
|
|
);
|
|
border-radius: 50% 50% 0 0;
|
|
}
|
|
|
|
.orchard-row.left {
|
|
left: -4%;
|
|
transform: rotate(8deg);
|
|
}
|
|
|
|
.orchard-row.center {
|
|
left: 31%;
|
|
}
|
|
|
|
.orchard-row.right {
|
|
right: -4%;
|
|
transform: rotate(-8deg);
|
|
}
|
|
|
|
.preview-body {
|
|
margin-top: -1.4rem;
|
|
position: relative;
|
|
z-index: 1;
|
|
padding: 1rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: 1.2rem;
|
|
background: rgba(255, 255, 255, 0.96);
|
|
}
|
|
|
|
.preview-header a {
|
|
padding: 0.65rem 0.8rem;
|
|
border-radius: 0.8rem;
|
|
border: 1px solid var(--line-strong);
|
|
background: #fff;
|
|
}
|
|
|
|
.preview-facts {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 0.75rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.preview-facts article {
|
|
padding: 0.85rem;
|
|
border-radius: 0.95rem;
|
|
background: var(--panel-soft);
|
|
border: 1px solid var(--line);
|
|
}
|
|
|
|
.preview-facts span {
|
|
display: block;
|
|
margin-bottom: 0.35rem;
|
|
color: var(--muted);
|
|
font-size: 0.84rem;
|
|
}
|
|
|
|
.table-wrap {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
min-width: 46rem;
|
|
border-collapse: separate;
|
|
border-spacing: 0 0.7rem;
|
|
}
|
|
|
|
th,
|
|
td {
|
|
padding: 1rem 1rem;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
th {
|
|
color: var(--muted);
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
tbody td {
|
|
background: var(--panel-soft);
|
|
border-top: 1px solid var(--line);
|
|
border-bottom: 1px solid var(--line);
|
|
color: #213029;
|
|
}
|
|
|
|
tbody td:first-child {
|
|
border-left: 1px solid var(--line);
|
|
border-radius: 1rem 0 0 1rem;
|
|
}
|
|
|
|
tbody td:last-child {
|
|
border-right: 1px solid var(--line);
|
|
border-radius: 0 1rem 1rem 0;
|
|
}
|
|
|
|
.task-cell {
|
|
min-width: 18rem;
|
|
}
|
|
|
|
.table-item,
|
|
.owner-chip,
|
|
.due-block {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.8rem;
|
|
}
|
|
|
|
.table-item strong,
|
|
.owner-chip strong,
|
|
.due-block strong {
|
|
display: block;
|
|
font-size: 0.96rem;
|
|
}
|
|
|
|
.table-item span:last-child,
|
|
.due-block span {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 0.82rem;
|
|
margin-top: 0.18rem;
|
|
}
|
|
|
|
.task-icon,
|
|
.owner-chip span {
|
|
width: 2.3rem;
|
|
height: 2.3rem;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 0.8rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.task-icon.positive {
|
|
color: var(--green-deep);
|
|
background: #e8f7ee;
|
|
}
|
|
|
|
.task-icon.warning {
|
|
color: #b16b1e;
|
|
background: #fff3e3;
|
|
}
|
|
|
|
.task-icon.neutral {
|
|
color: #54675e;
|
|
background: #edf2ef;
|
|
}
|
|
|
|
.owner-chip span {
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.due-block {
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
}
|
|
|
|
.status-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 999px;
|
|
padding: 0.38rem 0.72rem;
|
|
font-size: 0.84rem;
|
|
font-weight: 600;
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
.status-chip.positive {
|
|
color: var(--green-deep);
|
|
background: var(--green-soft);
|
|
}
|
|
|
|
.status-chip.warning {
|
|
color: #a9681d;
|
|
background: #fff6e6;
|
|
}
|
|
|
|
.status-chip.neutral {
|
|
color: #52635a;
|
|
background: #eef3f0;
|
|
}
|
|
|
|
.summary-list {
|
|
display: grid;
|
|
gap: 0.7rem;
|
|
}
|
|
|
|
.summary-list article {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 0.9rem 0;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.summary-list article:last-child {
|
|
border-bottom: none;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.summary-name {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.7rem;
|
|
}
|
|
|
|
.summary-dot {
|
|
width: 0.75rem;
|
|
height: 0.75rem;
|
|
border-radius: 999px;
|
|
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@media (max-width: 1320px) {
|
|
.workspace-banner {
|
|
align-items: stretch;
|
|
}
|
|
|
|
.focus-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.dashboard-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
grid-template-areas:
|
|
'market gauge'
|
|
'metrics metrics';
|
|
}
|
|
|
|
.metric-stack {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1120px) {
|
|
.analysis-grid,
|
|
.detail-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.focus-grid {
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.dashboard-grid {
|
|
grid-template-columns: 1fr;
|
|
grid-template-areas:
|
|
'market'
|
|
'gauge'
|
|
'metrics';
|
|
}
|
|
|
|
.metric-stack {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.focus-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
.dashboard-intro,
|
|
.intro-actions,
|
|
.workspace-banner,
|
|
.card-toolbar,
|
|
.metric-head,
|
|
.preview-header,
|
|
.chart-actions,
|
|
.market-layout {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.preview-facts,
|
|
.signin-form {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.field-emblem {
|
|
width: 6.5rem;
|
|
height: 6.5rem;
|
|
}
|
|
|
|
.hero-value {
|
|
font-size: 2.4rem;
|
|
}
|
|
|
|
.month-row {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
row-gap: 0.5rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.workspace-banner,
|
|
.panel-card,
|
|
.preview-body {
|
|
padding: 1rem;
|
|
}
|
|
|
|
table,
|
|
thead,
|
|
tbody,
|
|
tr,
|
|
td {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
table {
|
|
min-width: 0;
|
|
border-spacing: 0;
|
|
}
|
|
|
|
thead {
|
|
display: none;
|
|
}
|
|
|
|
tbody {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
}
|
|
|
|
tbody tr {
|
|
padding: 0.3rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: 1rem;
|
|
background: var(--panel-soft);
|
|
}
|
|
|
|
tbody td {
|
|
padding: 0.78rem 0.8rem;
|
|
white-space: normal;
|
|
border: none;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
tbody td:first-child,
|
|
tbody td:last-child {
|
|
border: none;
|
|
border-radius: 0;
|
|
}
|
|
|
|
tbody td + td {
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
|
|
tbody td::before {
|
|
content: attr(data-label);
|
|
display: block;
|
|
margin-bottom: 0.35rem;
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.task-cell {
|
|
min-width: 0;
|
|
}
|
|
|
|
.table-item,
|
|
.owner-chip {
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|