v1.3 - client and admin scaffolding
This commit is contained in:
@@ -0,0 +1,884 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { clientSession } from '$lib/session';
|
||||
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() {
|
||||
feedback = '';
|
||||
errorMessage = '';
|
||||
|
||||
const validationWarnings = getDraftWarnings();
|
||||
if (validationWarnings.length) {
|
||||
errorMessage = validationWarnings[0];
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
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
|
||||
}))
|
||||
});
|
||||
|
||||
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);
|
||||
feedback = 'Mix saved with updated ingredient costing.';
|
||||
} catch (error) {
|
||||
errorMessage = 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>
|
||||
<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>{currency(row.marketValue)}</td>
|
||||
<td>{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
|
||||
<td>{currency(row.costPerKg, 4)}</td>
|
||||
<td>
|
||||
<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>{currency(row.lineCost)}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={row.notes}
|
||||
oninput={(event) => updateIngredientField(index, 'notes', (event.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="Optional row note"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<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: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
|
||||
}
|
||||
|
||||
.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%;
|
||||
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: 1180px) {
|
||||
.editor-grid,
|
||||
.metric-row,
|
||||
.meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-intro,
|
||||
.section-heading,
|
||||
.intro-actions,
|
||||
.editor-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user