981 lines
25 KiB
Svelte
981 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { goto, invalidateAll } from '$app/navigation';
|
|
import { api } from '$lib/api';
|
|
import { clientSession } from '$lib/session';
|
|
import { toast } from '$lib/toast';
|
|
import type { Mix, MixIngredient, RawMaterial } from '$lib/types';
|
|
|
|
type DraftIngredient = {
|
|
id: number | null;
|
|
raw_material_id: number | null;
|
|
quantity_kg: number;
|
|
notes: string;
|
|
};
|
|
|
|
let {
|
|
rawMaterials,
|
|
initialMix = null
|
|
}: {
|
|
rawMaterials: RawMaterial[];
|
|
initialMix?: Mix | null;
|
|
} = $props();
|
|
const getInitialMix = () => initialMix;
|
|
|
|
let savedMix = $state<Mix | null>(getInitialMix());
|
|
let mixName = $state(getInitialMix()?.name ?? '');
|
|
let clientName = $state(getInitialMix()?.client_name ?? '');
|
|
let mixStatus = $state(getInitialMix()?.status ?? 'draft');
|
|
let mixVersion = $state(getInitialMix()?.version ?? 1);
|
|
let mixNotes = $state(getInitialMix()?.notes ?? '');
|
|
let draftIngredients = $state<DraftIngredient[]>([]);
|
|
let feedback = $state('');
|
|
let errorMessage = $state('');
|
|
let isSaving = $state(false);
|
|
|
|
function currency(value: number | null | undefined, digits = 2) {
|
|
if (value === null || value === undefined) {
|
|
return 'N/A';
|
|
}
|
|
|
|
return `$${value.toFixed(digits)}`;
|
|
}
|
|
|
|
function findRawMaterial(rawMaterialId: number | null) {
|
|
return rawMaterials.find((material) => material.id === rawMaterialId) ?? null;
|
|
}
|
|
|
|
function rowFromIngredient(ingredient: MixIngredient): DraftIngredient {
|
|
return {
|
|
id: ingredient.id,
|
|
raw_material_id: ingredient.raw_material_id,
|
|
quantity_kg: ingredient.quantity_kg,
|
|
notes: ingredient.notes ?? ''
|
|
};
|
|
}
|
|
|
|
function createEmptyIngredient(): DraftIngredient {
|
|
return {
|
|
id: null,
|
|
raw_material_id: rawMaterials[0]?.id ?? null,
|
|
quantity_kg: 0,
|
|
notes: ''
|
|
};
|
|
}
|
|
|
|
function loadDraftFromMix(mix: Mix | null) {
|
|
if (!mix) {
|
|
savedMix = null;
|
|
mixName = '';
|
|
clientName = '';
|
|
mixStatus = 'draft';
|
|
mixVersion = 1;
|
|
mixNotes = '';
|
|
draftIngredients = [createEmptyIngredient()];
|
|
return;
|
|
}
|
|
|
|
savedMix = mix;
|
|
mixName = mix.name;
|
|
clientName = mix.client_name;
|
|
mixStatus = mix.status;
|
|
mixVersion = mix.version ?? 1;
|
|
mixNotes = mix.notes ?? '';
|
|
draftIngredients = mix.ingredients.length ? mix.ingredients.map(rowFromIngredient) : [createEmptyIngredient()];
|
|
}
|
|
|
|
loadDraftFromMix(getInitialMix());
|
|
|
|
function resetDraft() {
|
|
feedback = '';
|
|
errorMessage = '';
|
|
loadDraftFromMix(savedMix);
|
|
}
|
|
|
|
function updateIngredientField(index: number, field: keyof DraftIngredient, value: string | number | null) {
|
|
draftIngredients = draftIngredients.map((row, rowIndex) =>
|
|
rowIndex === index
|
|
? {
|
|
...row,
|
|
[field]: value
|
|
}
|
|
: row
|
|
);
|
|
}
|
|
|
|
function addIngredientRow() {
|
|
draftIngredients = [...draftIngredients, createEmptyIngredient()];
|
|
}
|
|
|
|
function removeIngredientRow(index: number) {
|
|
draftIngredients = draftIngredients.filter((_, rowIndex) => rowIndex !== index);
|
|
|
|
if (!draftIngredients.length) {
|
|
draftIngredients = [createEmptyIngredient()];
|
|
}
|
|
}
|
|
|
|
function getCostPerKg(rawMaterialId: number | null) {
|
|
return findRawMaterial(rawMaterialId)?.current_price?.cost_per_kg ?? null;
|
|
}
|
|
|
|
function getDraftWarnings() {
|
|
const warnings: string[] = [];
|
|
const chosen = draftIngredients
|
|
.map((row) => row.raw_material_id)
|
|
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);
|
|
|
|
if (!mixName.trim()) {
|
|
warnings.push('Mix name is required.');
|
|
}
|
|
|
|
if (!clientName.trim()) {
|
|
warnings.push('Client name is required.');
|
|
}
|
|
|
|
if (!draftIngredients.length || draftIngredients.every((row) => !row.raw_material_id || row.quantity_kg <= 0)) {
|
|
warnings.push('Add at least one ingredient row with a positive quantity.');
|
|
}
|
|
|
|
if (new Set(chosen).size !== chosen.length) {
|
|
warnings.push('Each raw material can only appear once in a mix.');
|
|
}
|
|
|
|
draftIngredients.forEach((row, index) => {
|
|
if (row.raw_material_id === null) {
|
|
warnings.push(`Row ${index + 1} is missing a raw material.`);
|
|
}
|
|
if (row.quantity_kg <= 0) {
|
|
warnings.push(`Row ${index + 1} must have a quantity greater than zero.`);
|
|
}
|
|
if (row.raw_material_id !== null && getCostPerKg(row.raw_material_id) === null) {
|
|
warnings.push(`Row ${index + 1} has no active raw material price.`);
|
|
}
|
|
});
|
|
|
|
return warnings;
|
|
}
|
|
|
|
function getCleanIngredients() {
|
|
return draftIngredients
|
|
.filter((row) => row.raw_material_id !== null && row.quantity_kg > 0)
|
|
.map((row) => ({
|
|
id: row.id,
|
|
raw_material_id: row.raw_material_id as number,
|
|
quantity_kg: Number(row.quantity_kg),
|
|
notes: row.notes.trim() || null
|
|
}));
|
|
}
|
|
|
|
async function saveMix() {
|
|
const validationWarnings = getDraftWarnings();
|
|
if (validationWarnings.length) {
|
|
toast.error(validationWarnings[0]);
|
|
return;
|
|
}
|
|
|
|
isSaving = true;
|
|
const tid = toast.loading(savedMix ? 'Saving mix…' : 'Creating mix…');
|
|
|
|
try {
|
|
const cleanIngredients = getCleanIngredients();
|
|
|
|
if (!savedMix) {
|
|
const created = await api.createMix({
|
|
client_name: clientName.trim(),
|
|
name: mixName.trim(),
|
|
status: mixStatus,
|
|
version: mixVersion,
|
|
notes: mixNotes.trim() || null,
|
|
ingredients: cleanIngredients.map((row) => ({
|
|
raw_material_id: row.raw_material_id,
|
|
quantity_kg: row.quantity_kg,
|
|
notes: row.notes
|
|
}))
|
|
});
|
|
|
|
toast.dismiss(tid);
|
|
toast.success('Mix created');
|
|
await invalidateAll();
|
|
await goto(`/mixes/${created.id}`);
|
|
return;
|
|
}
|
|
|
|
await api.updateMix(savedMix.id, {
|
|
client_name: clientName.trim(),
|
|
name: mixName.trim(),
|
|
status: mixStatus,
|
|
version: mixVersion,
|
|
notes: mixNotes.trim() || null
|
|
});
|
|
|
|
const originalById = new Map(savedMix.ingredients.map((ingredient) => [ingredient.id, ingredient]));
|
|
const draftIds = new Set(cleanIngredients.filter((row) => row.id !== null).map((row) => row.id as number));
|
|
|
|
const rowsToDelete = savedMix.ingredients.filter((ingredient) => {
|
|
if (!draftIds.has(ingredient.id)) {
|
|
return true;
|
|
}
|
|
|
|
const draftRow = cleanIngredients.find((row) => row.id === ingredient.id);
|
|
return draftRow ? draftRow.raw_material_id !== ingredient.raw_material_id : false;
|
|
});
|
|
|
|
for (const ingredient of rowsToDelete) {
|
|
await api.deleteMixIngredient(savedMix.id, ingredient.id);
|
|
}
|
|
|
|
const rowsToAdd = cleanIngredients.filter((row) => {
|
|
if (row.id === null) {
|
|
return true;
|
|
}
|
|
|
|
const originalRow = originalById.get(row.id);
|
|
return originalRow ? originalRow.raw_material_id !== row.raw_material_id : true;
|
|
});
|
|
|
|
for (const row of rowsToAdd) {
|
|
await api.addMixIngredient(savedMix.id, {
|
|
raw_material_id: row.raw_material_id,
|
|
quantity_kg: row.quantity_kg,
|
|
notes: row.notes
|
|
});
|
|
}
|
|
|
|
const rowsToPatch = cleanIngredients.filter((row) => {
|
|
if (row.id === null) {
|
|
return false;
|
|
}
|
|
|
|
const originalRow = originalById.get(row.id);
|
|
if (!originalRow || originalRow.raw_material_id !== row.raw_material_id) {
|
|
return false;
|
|
}
|
|
|
|
return originalRow.quantity_kg !== row.quantity_kg || (originalRow.notes ?? null) !== row.notes;
|
|
});
|
|
|
|
for (const row of rowsToPatch) {
|
|
await api.updateMixIngredient(savedMix.id, row.id as number, {
|
|
quantity_kg: row.quantity_kg,
|
|
notes: row.notes
|
|
});
|
|
}
|
|
|
|
const refreshed = await api.mix(savedMix.id);
|
|
await invalidateAll();
|
|
loadDraftFromMix(refreshed);
|
|
toast.dismiss(tid);
|
|
toast.success('Mix saved');
|
|
} catch (error) {
|
|
toast.dismiss(tid);
|
|
toast.error(error instanceof Error ? error.message : 'Unable to save mix');
|
|
} finally {
|
|
isSaving = false;
|
|
}
|
|
}
|
|
|
|
const draftRows = $derived(
|
|
draftIngredients.map((row) => {
|
|
const material = findRawMaterial(row.raw_material_id);
|
|
const costPerKg = material?.current_price?.cost_per_kg ?? null;
|
|
const lineCost = costPerKg === null ? null : Number((row.quantity_kg * costPerKg).toFixed(4));
|
|
|
|
return {
|
|
...row,
|
|
marketValue: material?.current_price?.market_value ?? null,
|
|
wastePercentage: material?.current_price?.waste_percentage ?? null,
|
|
costPerKg,
|
|
lineCost
|
|
};
|
|
})
|
|
);
|
|
const totalMixKg = $derived(draftRows.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0));
|
|
const totalMixCost = $derived(Number(draftRows.reduce((sum, row) => sum + Number(row.lineCost || 0), 0).toFixed(4)));
|
|
const mixCostPerKg = $derived(totalMixKg > 0 ? Number((totalMixCost / totalMixKg).toFixed(4)) : null);
|
|
const draftWarnings = $derived(getDraftWarnings());
|
|
</script>
|
|
|
|
{#if !$clientSession}
|
|
<section class="locked-card">
|
|
<p class="eyebrow">Client Access Required</p>
|
|
<h2>Sign in on the Hunter Premium Produce home page before editing mixes.</h2>
|
|
<p>Mix worksheets use live raw material pricing and save directly into Mix Master.</p>
|
|
<a href="/">Return to sign-in</a>
|
|
</section>
|
|
{:else}
|
|
<section class="page-intro">
|
|
<div>
|
|
<p class="eyebrow">{savedMix ? 'Edit Mix' : 'New Mix'}</p>
|
|
<h2>{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}</h2>
|
|
<p>Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.</p>
|
|
</div>
|
|
|
|
<div class="intro-actions">
|
|
<a class="secondary-button" href="/mixes">Back to table</a>
|
|
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
|
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{#if feedback}
|
|
<p class="feedback success">{feedback}</p>
|
|
{/if}
|
|
|
|
{#if errorMessage}
|
|
<p class="feedback error">{errorMessage}</p>
|
|
{/if}
|
|
|
|
<section class="metric-row">
|
|
<article class="metric-card">
|
|
<span>Live Draft Kg</span>
|
|
<strong>{totalMixKg.toFixed(2)}</strong>
|
|
<p>Total quantity in the current worksheet</p>
|
|
</article>
|
|
|
|
<article class="metric-card">
|
|
<span>Live Draft Cost</span>
|
|
<strong>{currency(totalMixCost)}</strong>
|
|
<p>Calculated from current row factors</p>
|
|
</article>
|
|
|
|
<article class="metric-card">
|
|
<span>Cost / Kg</span>
|
|
<strong>{currency(mixCostPerKg, 4)}</strong>
|
|
<p>Current worksheet output</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="editor-grid">
|
|
<article class="editor-card">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Worksheet Meta</p>
|
|
<h3>Mix details</h3>
|
|
</div>
|
|
|
|
<div class="editor-actions">
|
|
<button class="secondary-button" type="button" onclick={resetDraft}>Reset</button>
|
|
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
|
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="meta-grid">
|
|
<label>
|
|
Mix Name
|
|
<input bind:value={mixName} placeholder="Hunter Orchard Blend" />
|
|
</label>
|
|
|
|
<label>
|
|
Client
|
|
<input bind:value={clientName} placeholder="Hunter Premium Produce" />
|
|
</label>
|
|
|
|
<label>
|
|
Status
|
|
<select bind:value={mixStatus}>
|
|
<option value="draft">Draft</option>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
Version
|
|
<input bind:value={mixVersion} type="number" min="1" step="1" />
|
|
</label>
|
|
</div>
|
|
|
|
<label class="notes-field">
|
|
Notes
|
|
<textarea bind:value={mixNotes} rows="3" placeholder="Internal mixing notes, process assumptions, or version comments"></textarea>
|
|
</label>
|
|
|
|
<div class="section-heading spreadsheet-head">
|
|
<div>
|
|
<p class="eyebrow">Spreadsheet Rows</p>
|
|
<h4>Ingredient builder</h4>
|
|
</div>
|
|
<button class="secondary-button" type="button" onclick={addIngredientRow}>Add Row</button>
|
|
</div>
|
|
|
|
<div class="sheet-wrap">
|
|
<table class="sheet-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Raw Material</th>
|
|
<th>Market Value</th>
|
|
<th>Waste %</th>
|
|
<th>Cost / Kg</th>
|
|
<th>Qty Kg</th>
|
|
<th>Line Cost</th>
|
|
<th>Notes</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each draftRows as row, index}
|
|
<tr>
|
|
<td data-label="Raw Material">
|
|
<select
|
|
value={row.raw_material_id ?? ''}
|
|
onchange={(event) =>
|
|
updateIngredientField(
|
|
index,
|
|
'raw_material_id',
|
|
Number((event.currentTarget as HTMLSelectElement).value) || null
|
|
)}
|
|
>
|
|
<option value="">Select material</option>
|
|
{#each rawMaterials as material}
|
|
<option value={material.id}>{material.name}</option>
|
|
{/each}
|
|
</select>
|
|
</td>
|
|
<td data-label="Market Value">{currency(row.marketValue)}</td>
|
|
<td data-label="Waste %">{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
|
|
<td data-label="Cost / Kg">{currency(row.costPerKg, 4)}</td>
|
|
<td data-label="Qty Kg">
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={row.quantity_kg}
|
|
oninput={(event) =>
|
|
updateIngredientField(index, 'quantity_kg', Number((event.currentTarget as HTMLInputElement).value))
|
|
}
|
|
/>
|
|
</td>
|
|
<td data-label="Line Cost">{currency(row.lineCost)}</td>
|
|
<td data-label="Notes">
|
|
<input
|
|
type="text"
|
|
value={row.notes}
|
|
oninput={(event) => updateIngredientField(index, 'notes', (event.currentTarget as HTMLInputElement).value)}
|
|
placeholder="Optional row note"
|
|
/>
|
|
</td>
|
|
<td data-label="Row Action">
|
|
<button class="icon-delete" type="button" onclick={() => removeIngredientRow(index)}>Remove</button>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</article>
|
|
|
|
<aside class="sidebar-stack">
|
|
<article class="summary-card">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Live Totals</p>
|
|
<h3>Worksheet summary</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="summary-grid">
|
|
<article>
|
|
<span>Total Kg</span>
|
|
<strong>{totalMixKg.toFixed(2)}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Total Cost</span>
|
|
<strong>{currency(totalMixCost)}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Cost / Kg</span>
|
|
<strong>{currency(mixCostPerKg, 4)}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Rows</span>
|
|
<strong>{draftRows.length}</strong>
|
|
</article>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="summary-card">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Calculation Factors</p>
|
|
<h3>What drives cost</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="factor-list">
|
|
<article>
|
|
<strong>Market value</strong>
|
|
<span>Each raw material row uses the active market value from the raw materials module.</span>
|
|
</article>
|
|
<article>
|
|
<strong>Waste percentage</strong>
|
|
<span>Material loss is included in the computed cost per kg used by the worksheet.</span>
|
|
</article>
|
|
<article>
|
|
<strong>Kg per unit</strong>
|
|
<span>Unit conversion already affects each raw material cost per kg before line costs are calculated.</span>
|
|
</article>
|
|
<article>
|
|
<strong>Quantity used</strong>
|
|
<span>Line cost is recalculated instantly from quantity times cost per kg.</span>
|
|
</article>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="summary-card">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Draft Checks</p>
|
|
<h3>Validation</h3>
|
|
</div>
|
|
</div>
|
|
|
|
{#if draftWarnings.length}
|
|
<div class="warning-list">
|
|
{#each draftWarnings as warning}
|
|
<article>{warning}</article>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="healthy-card">
|
|
<strong>Ready to save</strong>
|
|
<p>This draft has the required metadata and valid ingredient rows.</p>
|
|
</div>
|
|
{/if}
|
|
</article>
|
|
</aside>
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
h2,
|
|
h3,
|
|
h4,
|
|
p {
|
|
margin: 0;
|
|
}
|
|
|
|
.eyebrow {
|
|
color: #7f8e85;
|
|
font-size: 0.74rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.locked-card,
|
|
.page-intro,
|
|
.feedback,
|
|
.metric-card,
|
|
.editor-card,
|
|
.summary-card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 1.16rem;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.locked-card,
|
|
.page-intro,
|
|
.feedback,
|
|
.metric-row,
|
|
.editor-grid {
|
|
margin-bottom: 1.12rem;
|
|
}
|
|
|
|
.locked-card,
|
|
.page-intro,
|
|
.editor-card,
|
|
.summary-card {
|
|
padding: 1.08rem;
|
|
}
|
|
|
|
.locked-card {
|
|
display: grid;
|
|
gap: 0.62rem;
|
|
max-width: 40rem;
|
|
}
|
|
|
|
.locked-card h2,
|
|
.page-intro h2 {
|
|
margin: 0.3rem 0 0.4rem;
|
|
font-size: clamp(1.56rem, 3vw, 2.02rem);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.locked-card p:last-of-type,
|
|
.page-intro p:last-child,
|
|
.metric-card p,
|
|
.summary-card span,
|
|
.factor-list span,
|
|
.healthy-card p {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.locked-card a {
|
|
color: var(--green-deep);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.page-intro,
|
|
.section-heading,
|
|
.intro-actions,
|
|
.editor-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.68rem;
|
|
}
|
|
|
|
.page-intro {
|
|
align-items: end;
|
|
}
|
|
|
|
.primary-button,
|
|
.secondary-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 0.82rem;
|
|
padding: 0.74rem 0.9rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.primary-button {
|
|
border: none;
|
|
color: #fff;
|
|
background: var(--color-brand);
|
|
box-shadow: none;
|
|
}
|
|
|
|
.secondary-button {
|
|
border: 1px solid var(--line-strong);
|
|
color: #304038;
|
|
background: #fff;
|
|
}
|
|
|
|
.primary-button:disabled,
|
|
.secondary-button:disabled {
|
|
opacity: 0.7;
|
|
cursor: wait;
|
|
}
|
|
|
|
.feedback {
|
|
padding: 0.86rem 0.94rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.feedback.success {
|
|
color: var(--green-deep);
|
|
border-color: #d8ecdf;
|
|
background: #f6fcf8;
|
|
}
|
|
|
|
.feedback.error {
|
|
color: #a03737;
|
|
border-color: #f0d9d9;
|
|
background: #fff8f8;
|
|
}
|
|
|
|
.metric-row,
|
|
.editor-grid,
|
|
.meta-grid,
|
|
.summary-grid {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
}
|
|
|
|
.metric-row {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
|
|
.metric-card {
|
|
padding: 1.04rem 1.08rem;
|
|
}
|
|
|
|
.metric-card span {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 0.84rem;
|
|
}
|
|
|
|
.metric-card strong {
|
|
display: block;
|
|
margin: 0.48rem 0 0.26rem;
|
|
font-size: 1.72rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.section-heading {
|
|
align-items: flex-start;
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
|
|
.section-heading h3,
|
|
.section-heading h4 {
|
|
font-size: 1.02rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.editor-grid {
|
|
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.75fr);
|
|
}
|
|
|
|
.editor-card,
|
|
.summary-card {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
}
|
|
|
|
.meta-grid {
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
label {
|
|
display: grid;
|
|
gap: 0.32rem;
|
|
color: #53645b;
|
|
font-size: 0.86rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
input,
|
|
textarea,
|
|
select {
|
|
width: 100%;
|
|
padding: 0.82rem 0.88rem;
|
|
border: 1px solid var(--line-strong);
|
|
border-radius: 0.88rem;
|
|
background: var(--panel-soft);
|
|
color: var(--text);
|
|
}
|
|
|
|
.sheet-wrap {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.sheet-table {
|
|
width: 100%;
|
|
min-width: 58rem;
|
|
border-collapse: separate;
|
|
border-spacing: 0 0.54rem;
|
|
}
|
|
|
|
.sheet-table th,
|
|
.sheet-table td {
|
|
padding: 0.88rem 0.92rem;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.sheet-table th {
|
|
color: var(--muted);
|
|
font-size: 0.74rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.sheet-table tbody td {
|
|
background: var(--panel-soft);
|
|
border-top: 1px solid var(--line);
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.sheet-table tbody td:first-child {
|
|
border-left: 1px solid var(--line);
|
|
border-radius: 0.92rem 0 0 0.92rem;
|
|
}
|
|
|
|
.sheet-table tbody td:last-child {
|
|
border-right: 1px solid var(--line);
|
|
border-radius: 0 0.92rem 0.92rem 0;
|
|
}
|
|
|
|
.sheet-table input,
|
|
.sheet-table select {
|
|
min-width: 8rem;
|
|
padding: 0.7rem 0.78rem;
|
|
background: #fff;
|
|
}
|
|
|
|
.icon-delete {
|
|
border: 1px solid #eed8d8;
|
|
border-radius: 0.74rem;
|
|
padding: 0.64rem 0.78rem;
|
|
color: #a34a4a;
|
|
background: #fff7f7;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.sidebar-stack,
|
|
.factor-list,
|
|
.warning-list {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
}
|
|
|
|
.summary-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.summary-grid article,
|
|
.factor-list article {
|
|
padding: 0.88rem 0.94rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: 0.92rem;
|
|
background: var(--panel-soft);
|
|
}
|
|
|
|
.summary-grid strong {
|
|
display: block;
|
|
margin-top: 0.28rem;
|
|
font-size: 1.08rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.factor-list strong,
|
|
.healthy-card strong {
|
|
display: block;
|
|
margin-bottom: 0.22rem;
|
|
font-size: 0.94rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.warning-list article,
|
|
.healthy-card {
|
|
padding: 0.9rem 0.94rem;
|
|
border-radius: 0.92rem;
|
|
}
|
|
|
|
.warning-list article {
|
|
border: 1px solid #f1e2c2;
|
|
background: #fffaf2;
|
|
color: #8d5d21;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.healthy-card {
|
|
border: 1px solid var(--line);
|
|
background: var(--panel-soft);
|
|
}
|
|
|
|
@media (max-width: 1240px) {
|
|
.editor-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.sidebar-stack {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.metric-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.meta-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.page-intro,
|
|
.section-heading,
|
|
.intro-actions,
|
|
.editor-actions {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.intro-actions,
|
|
.editor-actions,
|
|
.primary-button,
|
|
.secondary-button {
|
|
width: 100%;
|
|
}
|
|
|
|
.summary-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.meta-grid,
|
|
.sidebar-stack {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 880px) {
|
|
.sheet-table,
|
|
.sheet-table thead,
|
|
.sheet-table tbody,
|
|
.sheet-table tr,
|
|
.sheet-table td {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
.sheet-table {
|
|
min-width: 0;
|
|
border-spacing: 0;
|
|
}
|
|
|
|
.sheet-table thead {
|
|
display: none;
|
|
}
|
|
|
|
.sheet-table tbody {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
}
|
|
|
|
.sheet-table tbody tr {
|
|
padding: 0.35rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: 1rem;
|
|
background: var(--panel-soft);
|
|
}
|
|
|
|
.sheet-table tbody td {
|
|
padding: 0.78rem 0.8rem;
|
|
white-space: normal;
|
|
border: none;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
.sheet-table tbody td:first-child,
|
|
.sheet-table tbody td:last-child {
|
|
border: none;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.sheet-table tbody td + td {
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
|
|
.sheet-table 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;
|
|
}
|
|
|
|
.sheet-table input,
|
|
.sheet-table select,
|
|
.icon-delete {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
}
|
|
</style>
|