Files
data-entry-app/frontend/src/routes/+page.svelte
T

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>