feat(tradein): «Что-если» interactive recompute on estimate result #686
3 changed files with 537 additions and 0 deletions
|
|
@ -26,6 +26,7 @@ import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
|||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
|
||||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||
import { WhatIfPanel } from "@/components/trade-in/WhatIfPanel";
|
||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||
import { useQuota } from "@/lib/useQuota";
|
||||
|
|
@ -115,6 +116,29 @@ export default function TradeInPage() {
|
|||
}
|
||||
: null);
|
||||
|
||||
// Надёжный input для «Что-если»: предпочитаем поля самой оценки (есть и при
|
||||
// restore по ?id=, где resultData.input — stub с нулями), откатываемся на input.
|
||||
// address/rooms обязательны для payload — берём из estimate, иначе из input.
|
||||
const whatIfInput: TradeInEstimateInput | null = resultData
|
||||
? {
|
||||
...resultData.input,
|
||||
address: resultData.estimate.target_address ?? resultData.input.address,
|
||||
rooms: resultData.estimate.rooms ?? resultData.input.rooms,
|
||||
area_m2: resultData.estimate.area_m2 ?? resultData.input.area_m2,
|
||||
floor: resultData.estimate.floor ?? resultData.input.floor,
|
||||
total_floors: resultData.estimate.total_floors ?? resultData.input.total_floors,
|
||||
year_built: resultData.estimate.year_built ?? resultData.input.year_built,
|
||||
house_type: resultData.estimate.house_type ?? resultData.input.house_type,
|
||||
repair_state: resultData.estimate.repair_state ?? resultData.input.repair_state,
|
||||
has_balcony: resultData.estimate.has_balcony ?? resultData.input.has_balcony,
|
||||
}
|
||||
: null;
|
||||
// Гейтим панель: нужен валидный адрес + площадь, иначе recompute-payload невалиден.
|
||||
const whatIfReady =
|
||||
whatIfInput !== null &&
|
||||
whatIfInput.address.trim().length >= 3 &&
|
||||
whatIfInput.area_m2 > 10;
|
||||
|
||||
function handleResubmit(patch: { house_type?: HouseType; repair_state?: RepairState }) {
|
||||
if (!resultData) return;
|
||||
const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch };
|
||||
|
|
@ -202,6 +226,12 @@ export default function TradeInPage() {
|
|||
|
||||
{resultData ? (
|
||||
<>
|
||||
{whatIfReady && whatIfInput && (
|
||||
<WhatIfPanel
|
||||
baseEstimate={resultData.estimate}
|
||||
baseInput={whatIfInput}
|
||||
/>
|
||||
)}
|
||||
<HeroSummary
|
||||
estimate={resultData.estimate}
|
||||
input={resultData.input}
|
||||
|
|
|
|||
364
tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
Normal file
364
tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* WhatIfPanel — «Что-если» интерактивный пересчёт оценки на результате.
|
||||
* Web-преимущество над статичным PDF: продавец/агент крутит ключевые
|
||||
* value-drivers (ремонт, этаж, площадь, балкон) и видит, как меняется оценка,
|
||||
* не перезаполняя форму. Demo wow: «а если сделать ремонт — вот +X».
|
||||
*
|
||||
* Использует ТОТ ЖЕ POST /api/v1/trade-in/estimate (useEstimateMutation) — не
|
||||
* параллельный API-путь. Запрос дебаунсится (~700 мс) и стреляет только если
|
||||
* значение реально изменилось относительно последнего отправленного.
|
||||
* Baseline (исходная оценка) держится снаружи и не затрагивается — панель
|
||||
* показывает ДЕЛЬТУ к нему и не клобберит остальные карточки страницы.
|
||||
*
|
||||
* Каждый estimate ходит в скраперы/DaData, поэтому: debounce + skip-if-unchanged
|
||||
* + ручная кнопка «Пересчитать». Авто-режим включён по умолчанию (autoMode),
|
||||
* но переключается чекбоксом «Авто-пересчёт».
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
AggregatedEstimate,
|
||||
RepairState,
|
||||
TradeInEstimateInput,
|
||||
} from "@/types/trade-in";
|
||||
import { useEstimateMutation } from "@/lib/trade-in-api";
|
||||
|
||||
interface Props {
|
||||
/** Исходная оценка-baseline — диффим относительно неё. */
|
||||
baseEstimate: AggregatedEstimate;
|
||||
/** Исходный input — основа для патчей (модифицируем копию). */
|
||||
baseInput: TradeInEstimateInput;
|
||||
}
|
||||
|
||||
const REPAIR_OPTIONS: ReadonlyArray<{ value: RepairState; label: string }> = [
|
||||
{ value: "needs_repair", label: "Требует ремонта" },
|
||||
{ value: "standard", label: "Жилое" },
|
||||
{ value: "good", label: "Хороший" },
|
||||
{ value: "excellent", label: "Евроремонт" },
|
||||
];
|
||||
|
||||
const DEBOUNCE_MS = 700;
|
||||
|
||||
/** Цена-итог, по которой считаем дельту: ожидаемая сделка, иначе медиана. */
|
||||
function headlinePrice(e: AggregatedEstimate): number {
|
||||
const sold = e.expected_sold_price_rub;
|
||||
return typeof sold === "number" && sold > 0 ? sold : e.median_price_rub;
|
||||
}
|
||||
|
||||
function formatMln(rub: number): string {
|
||||
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
|
||||
}
|
||||
|
||||
function formatDeltaMln(rub: number): string {
|
||||
const sign = rub > 0 ? "+" : rub < 0 ? "−" : "";
|
||||
const abs = Math.abs(rub);
|
||||
return `${sign}${(abs / 1_000_000).toFixed(2).replace(".", ",")} млн ₽`;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
repair_state: RepairState | "";
|
||||
floor: number | null;
|
||||
area_m2: number;
|
||||
has_balcony: boolean;
|
||||
}
|
||||
|
||||
function draftFromInput(input: TradeInEstimateInput, est: AggregatedEstimate): Draft {
|
||||
return {
|
||||
repair_state: (est.repair_state ?? input.repair_state ?? "") as RepairState | "",
|
||||
floor: est.floor ?? input.floor ?? null,
|
||||
area_m2: est.area_m2 ?? input.area_m2 ?? 0,
|
||||
has_balcony: est.has_balcony ?? input.has_balcony ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Сериализуемый ключ драфта — для skip-if-unchanged сравнения. */
|
||||
function draftKey(d: Draft): string {
|
||||
return `${d.repair_state}|${d.floor ?? ""}|${d.area_m2}|${d.has_balcony ? 1 : 0}`;
|
||||
}
|
||||
|
||||
function applyDraft(base: TradeInEstimateInput, d: Draft): TradeInEstimateInput {
|
||||
const out: TradeInEstimateInput = {
|
||||
...base,
|
||||
area_m2: d.area_m2,
|
||||
floor: d.floor,
|
||||
has_balcony: d.has_balcony,
|
||||
};
|
||||
if (d.repair_state) out.repair_state = d.repair_state;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
|
||||
const initialDraft = useMemo(
|
||||
() => draftFromInput(baseInput, baseEstimate),
|
||||
[baseInput, baseEstimate],
|
||||
);
|
||||
|
||||
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||
const [autoMode, setAutoMode] = useState(true);
|
||||
// Результат последнего what-if пересчёта (локальный, не трогает страницу).
|
||||
const [whatIfEstimate, setWhatIfEstimate] = useState<AggregatedEstimate | null>(null);
|
||||
|
||||
const mutation = useEstimateMutation();
|
||||
|
||||
// Сброс при смене baseline (новая оценка из формы / restore по ?id=).
|
||||
useEffect(() => {
|
||||
setDraft(initialDraft);
|
||||
setWhatIfEstimate(null);
|
||||
}, [initialDraft]);
|
||||
|
||||
const baseKey = useMemo(() => draftKey(initialDraft), [initialDraft]);
|
||||
const currentKey = draftKey(draft);
|
||||
const isDirty = currentKey !== baseKey;
|
||||
|
||||
const totalFloors = baseEstimate.total_floors ?? baseInput.total_floors ?? null;
|
||||
const floorMax = totalFloors && totalFloors > 0 ? totalFloors : 100;
|
||||
|
||||
// Ключ последнего ОТПРАВЛЕННОГО драфта — чтобы не дёргать бэкенд на тех же значениях.
|
||||
const lastSentKeyRef = useRef<string>(baseKey);
|
||||
useEffect(() => {
|
||||
lastSentKeyRef.current = baseKey;
|
||||
}, [baseKey]);
|
||||
|
||||
const recompute = useCallback(
|
||||
(d: Draft) => {
|
||||
const key = draftKey(d);
|
||||
if (key === lastSentKeyRef.current) return; // ничего не изменилось
|
||||
lastSentKeyRef.current = key;
|
||||
mutation.mutate(applyDraft(baseInput, d), {
|
||||
onSuccess: (est) => setWhatIfEstimate(est),
|
||||
});
|
||||
},
|
||||
[baseInput, mutation],
|
||||
);
|
||||
|
||||
// Debounced auto-recompute — только когда autoMode и draft реально отличается.
|
||||
useEffect(() => {
|
||||
if (!autoMode) return;
|
||||
if (currentKey === lastSentKeyRef.current) return;
|
||||
const t = setTimeout(() => recompute(draft), DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
// currentKey покрывает все поля draft; recompute стабилен по baseInput.
|
||||
}, [autoMode, currentKey, draft, recompute]);
|
||||
|
||||
function reset() {
|
||||
setDraft(initialDraft);
|
||||
setWhatIfEstimate(null);
|
||||
lastSentKeyRef.current = baseKey;
|
||||
}
|
||||
|
||||
const isPending = mutation.isPending;
|
||||
// Активный результат для дельты: what-if если он отражает текущий драфт.
|
||||
const shownEstimate =
|
||||
isDirty && whatIfEstimate ? whatIfEstimate : null;
|
||||
const basePrice = headlinePrice(baseEstimate);
|
||||
const newPrice = shownEstimate ? headlinePrice(shownEstimate) : null;
|
||||
const delta = newPrice !== null ? newPrice - basePrice : null;
|
||||
const deltaPct = delta !== null && basePrice > 0 ? (delta / basePrice) * 100 : null;
|
||||
|
||||
function patch(p: Partial<Draft>) {
|
||||
setDraft((s) => ({ ...s, ...p }));
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="card whatif-card">
|
||||
<div className="whatif-head">
|
||||
<div>
|
||||
<div className="section-kicker">Интерактив · Что-если</div>
|
||||
<h3 className="whatif-title">Поменяйте параметры — оценка пересчитается</h3>
|
||||
</div>
|
||||
<label className="whatif-auto" title="Автоматически пересчитывать после паузы">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoMode}
|
||||
onChange={(e) => setAutoMode(e.target.checked)}
|
||||
/>
|
||||
<span>Авто-пересчёт</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="whatif-body">
|
||||
{/* Состояние ремонта — segmented */}
|
||||
<div className="whatif-field">
|
||||
<span className="whatif-label">Состояние ремонта</span>
|
||||
<div className="whatif-segmented" role="group" aria-label="Состояние ремонта">
|
||||
{REPAIR_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`whatif-seg${draft.repair_state === opt.value ? " is-active" : ""}`}
|
||||
aria-pressed={draft.repair_state === opt.value}
|
||||
onClick={() => patch({ repair_state: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="whatif-row">
|
||||
{/* Этаж — stepper + slider */}
|
||||
<div className="whatif-field">
|
||||
<span className="whatif-label">
|
||||
Этаж{" "}
|
||||
<span className="whatif-hint">
|
||||
{draft.floor ?? "—"}
|
||||
{totalFloors ? ` / ${totalFloors}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<div className="whatif-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="whatif-step-btn"
|
||||
aria-label="Этаж ниже"
|
||||
disabled={(draft.floor ?? 1) <= 1}
|
||||
onClick={() => patch({ floor: Math.max(1, (draft.floor ?? 1) - 1) })}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
className="whatif-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={floorMax}
|
||||
value={draft.floor ?? 1}
|
||||
onChange={(e) => patch({ floor: Number(e.target.value) })}
|
||||
aria-label="Этаж"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="whatif-step-btn"
|
||||
aria-label="Этаж выше"
|
||||
disabled={(draft.floor ?? 1) >= floorMax}
|
||||
onClick={() => patch({ floor: Math.min(floorMax, (draft.floor ?? 1) + 1) })}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Площадь — numeric ± */}
|
||||
<div className="whatif-field">
|
||||
<span className="whatif-label">
|
||||
Площадь <span className="whatif-hint">м²</span>
|
||||
</span>
|
||||
<div className="whatif-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="whatif-step-btn"
|
||||
aria-label="Меньше площадь"
|
||||
disabled={draft.area_m2 <= 11}
|
||||
onClick={() =>
|
||||
patch({ area_m2: Math.max(11, Math.round((draft.area_m2 - 1) * 10) / 10) })
|
||||
}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
className="control mono whatif-num"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min={11}
|
||||
max={499}
|
||||
value={draft.area_m2}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v)) patch({ area_m2: v });
|
||||
}}
|
||||
aria-label="Площадь м²"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="whatif-step-btn"
|
||||
aria-label="Больше площадь"
|
||||
disabled={draft.area_m2 >= 499}
|
||||
onClick={() =>
|
||||
patch({ area_m2: Math.min(499, Math.round((draft.area_m2 + 1) * 10) / 10) })
|
||||
}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Балкон toggle */}
|
||||
<label className="whatif-balcony">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.has_balcony}
|
||||
onChange={(e) => patch({ has_balcony: e.target.checked })}
|
||||
/>
|
||||
<span>Есть балкон / лоджия</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="whatif-foot">
|
||||
<div className="whatif-result" aria-live="polite">
|
||||
{isPending ? (
|
||||
<span className="whatif-loading">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
style={{ animation: "ti-spin 1s linear infinite" }}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Пересчитываем…
|
||||
</span>
|
||||
) : delta !== null && newPrice !== null ? (
|
||||
<>
|
||||
<span
|
||||
className={`whatif-delta${delta > 0 ? " up" : delta < 0 ? " down" : ""}`}
|
||||
>
|
||||
{formatDeltaMln(delta)}
|
||||
{deltaPct !== null && Math.abs(deltaPct) >= 0.1
|
||||
? ` (${delta >= 0 ? "+" : "−"}${Math.abs(deltaPct).toFixed(1)}%)`
|
||||
: ""}
|
||||
</span>
|
||||
<span className="whatif-newprice mono">
|
||||
новая оценка {formatMln(newPrice)} ₽
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="whatif-baseline mono">
|
||||
исходная оценка {formatMln(basePrice)} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="whatif-actions">
|
||||
{!autoMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary whatif-recalc"
|
||||
disabled={!isDirty || isPending}
|
||||
style={{ opacity: !isDirty || isPending ? 0.55 : 1 }}
|
||||
onClick={() => recompute(draft)}
|
||||
>
|
||||
Пересчитать
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={!isDirty && whatIfEstimate === null}
|
||||
onClick={reset}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutation.error && (
|
||||
<div className="whatif-error">{mutation.error.message}</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -1883,3 +1883,146 @@
|
|||
background: var(--accent-2-soft);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
/* ── «Что-если» интерактивный пересчёт (web-преимущество над PDF) ──
|
||||
Компактная панель над HeroSummary: segmented ремонт + stepper/slider этаж +
|
||||
numeric площадь + балкон. Делает тот же POST /estimate (debounced) и диффит
|
||||
к baseline. Все цвета — через brand-токены (работает в .brand-active). */
|
||||
.whatif-card {
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.whatif-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 22px 10px;
|
||||
}
|
||||
.whatif-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 2px 0 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.whatif-auto {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.whatif-auto input { width: 14px; height: 14px; cursor: pointer; }
|
||||
.whatif-body {
|
||||
padding: 4px 22px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.whatif-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.whatif-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.whatif-hint { font-size: 11px; font-weight: 400; color: var(--muted); }
|
||||
.whatif-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.whatif-segmented {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--surface-2);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.whatif-seg {
|
||||
flex: 1 1 auto;
|
||||
min-width: 88px;
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
background: transparent;
|
||||
color: var(--fg-2);
|
||||
cursor: pointer;
|
||||
transition: background .12s, color .12s, border-color .12s;
|
||||
}
|
||||
.whatif-seg:hover { background: var(--surface); }
|
||||
.whatif-seg.is-active {
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.whatif-stepper { display: flex; align-items: center; gap: 8px; }
|
||||
.whatif-step-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.whatif-step-btn:hover:not(:disabled) { background: var(--accent-soft); }
|
||||
.whatif-step-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.whatif-slider { flex: 1 1 auto; accent-color: var(--accent); cursor: pointer; }
|
||||
.whatif-num { width: 84px; text-align: center; padding: 6px 8px; }
|
||||
.whatif-balcony {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.whatif-balcony input { width: 14px; height: 14px; cursor: pointer; }
|
||||
.whatif-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
}
|
||||
.whatif-result { display: flex; flex-direction: column; gap: 2px; min-height: 34px; justify-content: center; }
|
||||
.whatif-delta { font-size: 18px; font-weight: 700; color: var(--muted); }
|
||||
.whatif-delta.up { color: var(--success); }
|
||||
.whatif-delta.down { color: var(--danger); }
|
||||
.whatif-newprice { font-size: 12px; color: var(--muted); }
|
||||
.whatif-baseline { font-size: 13px; color: var(--fg-2); }
|
||||
.whatif-loading { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); }
|
||||
.whatif-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.whatif-recalc { padding: 8px 16px; }
|
||||
.whatif-error {
|
||||
margin: 0 22px 16px;
|
||||
padding: 10px 12px;
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.whatif-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@keyframes ti-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue