Refactor: Modular calculator architecture
Created modular system for calculators, added soap and candles calculators, universal components, updated backend
This commit is contained in:
parent
8ee73ad6c6
commit
02c7520c90
49
CHANGELOG.md
Normal file
49
CHANGELOG.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# История изменений
|
||||||
|
|
||||||
|
## [Не опубликовано] - Рефакторинг под модульную архитектуру
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
- **Модульная система калькуляторов**: Создана универсальная архитектура для добавления новых калькуляторов через конфигурационные файлы
|
||||||
|
- **Система типов** (`frontend/lib/calculator-types.ts`): Типизация для полей, шагов расчета, подитогов и конфигураций калькуляторов
|
||||||
|
- **Реестр калькуляторов** (`frontend/lib/calculator-registry.ts`): Централизованная система регистрации и управления калькуляторами
|
||||||
|
- **Универсальный компонент CalculatorEngine** (`frontend/components/CalculatorEngine.tsx`): Динамический рендеринг форм на основе конфигурации
|
||||||
|
- **Компонент меню выбора** (`frontend/components/CalculatorMenu.tsx`): Интерфейс для выбора калькулятора
|
||||||
|
- **Модуль калькулятора мыла** (`frontend/calculators/soap/`): Калькулятор мыла вынесен в отдельный модуль с конфигурацией
|
||||||
|
- **Модуль калькулятора свечей** (`frontend/calculators/candles/`): Добавлен пример калькулятора свечей для демонстрации модульной системы
|
||||||
|
- **Универсальный бэкенд**: Адаптирован `backend/bot.js` для работы с любыми типами калькуляторов
|
||||||
|
- **Команда /myid**: Добавлена команда в бота для получения chat_id пользователя
|
||||||
|
- **Документация**: Создано руководство по созданию новых калькуляторов (`frontend/docs/calculator-creation-guide.md`)
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
|
||||||
|
- **Главная страница** (`frontend/app/page.tsx`): Теперь отображает меню выбора калькулятора вместо прямого отображения калькулятора мыла
|
||||||
|
- **Бэкенд API** (`backend/bot.js`): Универсализирован для обработки данных от любых калькуляторов с поддержкой форматирования сообщений через конфигурацию
|
||||||
|
|
||||||
|
### Удалено
|
||||||
|
|
||||||
|
- **Старый компонент SoapCalculator** (`frontend/components/SoapCalculator.tsx`): Заменен на модульную систему
|
||||||
|
- **Старая логика расчетов** (`frontend/lib/calc.ts`): Перенесена в модуль калькулятора мыла
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
|
||||||
|
- **Отступы между полями и блоками расчета**: Добавлены правильные отступы между группами полей и соответствующими блоками расчета
|
||||||
|
- **Расчет цены за 100г**: Исправлена формула, теперь корректно использует дополнительные расчеты
|
||||||
|
- **NaN в расчетах**: Добавлены проверки на деление на ноль и пустые значения
|
||||||
|
- **URL API для локальной разработки**: Исправлен URL бэкенда для работы с localhost
|
||||||
|
|
||||||
|
### Технические детали
|
||||||
|
|
||||||
|
- Каждый калькулятор теперь состоит из конфигурационного файла (`config.ts`) и опционального файла с функциями расчета (`calc.ts`)
|
||||||
|
- Добавление нового калькулятора требует только создания модуля и регистрации в реестре
|
||||||
|
- Все поля, формулы и подитоги настраиваются через конфигурацию
|
||||||
|
- Поддержка различных типов полей: text, number, file
|
||||||
|
- Группировка полей через `groupName` с показом блоков расчета после групп через `showStepAfter`
|
||||||
|
- Динамическое форматирование сообщений для Telegram через функцию `formatTelegramMessage` в конфигурации
|
||||||
|
- Дополнительные расчеты могут зависеть от других дополнительных расчетов (передается 4-й параметр `additional`)
|
||||||
|
- Автоматическое определение URL API (localhost для разработки, продакшн для деплоя)
|
||||||
|
|
||||||
|
## Добавлено после первоначального релиза
|
||||||
|
|
||||||
|
- **Калькулятор свечей**: Добавлен полнофункциональный пример калькулятора для свечей (`frontend/calculators/candles/`) с демонстрацией всех возможностей системы
|
||||||
|
|
||||||
102
PLAN.md
Normal file
102
PLAN.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# План работ: Модульная архитектура калькуляторов
|
||||||
|
|
||||||
|
## Статус выполнения
|
||||||
|
|
||||||
|
- [x] Создать систему типов (calculator-types.ts) с FieldConfig, CalculationStep, SubtotalConfig, CalculatorConfig
|
||||||
|
- [x] Вынести калькулятор мыла в модуль (calculators/soap/config.ts и calc.ts)
|
||||||
|
- [x] Создать универсальный компонент CalculatorEngine.tsx для динамического рендеринга и расчетов
|
||||||
|
- [x] Создать компонент CalculatorMenu.tsx для выбора калькулятора на главной странице
|
||||||
|
- [x] Создать систему регистрации калькуляторов (calculator-registry.ts)
|
||||||
|
- [x] Обновить app/page.tsx для отображения меню выбора калькулятора
|
||||||
|
- [x] Адаптировать backend/bot.js для универсальной обработки разных калькуляторов
|
||||||
|
- [x] Удалить старые файлы SoapCalculator.tsx и calc.ts после миграции
|
||||||
|
- [x] Создать документацию по созданию калькуляторов (calculator-creation-guide.md)
|
||||||
|
- [x] Создать CHANGELOG.md с описанием изменений
|
||||||
|
- [x] Создать PLAN.md с планом работ и отслеживанием прогресса
|
||||||
|
|
||||||
|
## Выполненные задачи
|
||||||
|
|
||||||
|
### ✅ Этап 1: Система типов
|
||||||
|
- Создан файл `frontend/lib/calculator-types.ts`
|
||||||
|
- Определены типы: `FieldConfig`, `CalculationStep`, `SubtotalConfig`, `CalculatorConfig`
|
||||||
|
- Поддержка типов полей: text, number, file
|
||||||
|
- Типы для результатов расчетов: `CalculatorValues`, `CalculationResults`
|
||||||
|
|
||||||
|
### ✅ Этап 2: Модуль калькулятора мыла
|
||||||
|
- Создана папка `frontend/calculators/soap/`
|
||||||
|
- Создан файл `calc.ts` с функциями расчета
|
||||||
|
- Создан файл `config.ts` с полной конфигурацией калькулятора мыла
|
||||||
|
- Все поля, формулы и подитоги перенесены в модуль
|
||||||
|
|
||||||
|
### ✅ Этап 3: Универсальные компоненты
|
||||||
|
- Создан `CalculatorEngine.tsx` — динамический рендеринг на основе конфигурации
|
||||||
|
- Создан `CalculatorMenu.tsx` — меню выбора калькулятора
|
||||||
|
- Обновлен `app/page.tsx` для использования меню
|
||||||
|
|
||||||
|
### ✅ Этап 4: Система регистрации
|
||||||
|
- Создан `calculator-registry.ts` для управления калькуляторами
|
||||||
|
- Реализованы функции: `registerCalculator`, `getCalculator`, `getAllCalculators`
|
||||||
|
- Автоматическая инициализация при импорте
|
||||||
|
|
||||||
|
### ✅ Этап 5: Адаптация бэкенда
|
||||||
|
- Обновлен `backend/bot.js` для универсальной обработки
|
||||||
|
- Поддержка готовых сообщений из конфигурации калькулятора
|
||||||
|
- Fallback на универсальное форматирование при отсутствии `formatTelegramMessage`
|
||||||
|
|
||||||
|
### ✅ Этап 6: Очистка
|
||||||
|
- Удален старый `SoapCalculator.tsx`
|
||||||
|
- Удален старый `calc.ts`
|
||||||
|
- Все зависимости обновлены
|
||||||
|
|
||||||
|
### ✅ Этап 7: Документация
|
||||||
|
- Создано руководство `docs/calculator-creation-guide.md`
|
||||||
|
- Создан `CHANGELOG.md`
|
||||||
|
- Создан `PLAN.md` (этот файл)
|
||||||
|
|
||||||
|
## Следующие шаги (будущие улучшения)
|
||||||
|
|
||||||
|
### Возможные улучшения
|
||||||
|
- [ ] Добавить валидацию полей на основе конфигурации
|
||||||
|
- [ ] Добавить поддержку условных полей (показывать/скрывать в зависимости от значений)
|
||||||
|
- [x] Добавить поддержку групп полей с показом расчетов между группами
|
||||||
|
- [x] Создать пример калькулятора свечей для демонстрации
|
||||||
|
- [ ] Добавить тесты для модульной системы
|
||||||
|
- [ ] Улучшить обработку ошибок валидации
|
||||||
|
- [ ] Добавить поддержку единиц измерения (г, кг, шт и т.д.)
|
||||||
|
- [ ] Добавить сохранение истории расчетов (локально)
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
calculators/ # Модули калькуляторов
|
||||||
|
soap/
|
||||||
|
config.ts # Конфигурация калькулятора мыла
|
||||||
|
calc.ts # Функции расчета
|
||||||
|
candles/
|
||||||
|
config.ts # Конфигурация калькулятора свечей
|
||||||
|
calc.ts # Функции расчета
|
||||||
|
components/
|
||||||
|
CalculatorEngine.tsx # Универсальный компонент
|
||||||
|
CalculatorMenu.tsx # Меню выбора
|
||||||
|
lib/
|
||||||
|
calculator-types.ts # Типы системы
|
||||||
|
calculator-registry.ts # Реестр калькуляторов
|
||||||
|
app/
|
||||||
|
page.tsx # Главная страница с меню
|
||||||
|
docs/
|
||||||
|
calculator-creation-guide.md # Руководство
|
||||||
|
|
||||||
|
backend/
|
||||||
|
bot.js # Универсальный обработчик API
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как добавить новый калькулятор
|
||||||
|
|
||||||
|
1. Создать папку `frontend/calculators/[название]/`
|
||||||
|
2. Создать файл `config.ts` с конфигурацией
|
||||||
|
3. Опционально создать `calc.ts` для сложных расчетов
|
||||||
|
4. Зарегистрировать в `calculator-registry.ts`
|
||||||
|
|
||||||
|
Подробные инструкции в `frontend/docs/calculator-creation-guide.md`
|
||||||
|
|
||||||
131
backend/bot.js
131
backend/bot.js
@ -49,68 +49,76 @@ app.post(
|
|||||||
// Текстовые поля придут в req.body, файл — в req.file
|
// Текстовые поля придут в req.body, файл — в req.file
|
||||||
const {
|
const {
|
||||||
chat_id,
|
chat_id,
|
||||||
soapName,
|
calculator_id,
|
||||||
weight,
|
calculator_name,
|
||||||
basePrice,
|
telegram_message,
|
||||||
aromaPrice,
|
|
||||||
aromaWeight,
|
|
||||||
pigmentPrice,
|
|
||||||
pigmentWeight,
|
|
||||||
moldPrice,
|
|
||||||
box,
|
|
||||||
filler,
|
|
||||||
ribbon,
|
|
||||||
labelValue,
|
|
||||||
markup,
|
|
||||||
totalCost,
|
|
||||||
finalPrice,
|
|
||||||
pricePer100g,
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Проверяем обязательные поля
|
// Проверяем обязательные поля
|
||||||
if (!chat_id) {
|
if (!chat_id) {
|
||||||
return res.status(400).send('chat_id не передан');
|
return res.status(400).send('chat_id не передан');
|
||||||
}
|
}
|
||||||
if (!soapName) {
|
|
||||||
return res.status(400).send('soapName не передан');
|
// Если есть готовое сообщение для Telegram (форматированное на фронтенде),
|
||||||
|
// используем его, иначе формируем универсальное
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
if (telegram_message) {
|
||||||
|
// Используем готовое сообщение из конфигурации калькулятора
|
||||||
|
text = telegram_message;
|
||||||
|
} else {
|
||||||
|
// Формируем универсальное сообщение из всех полей
|
||||||
|
// (fallback для старых версий или калькуляторов без formatTelegramMessage)
|
||||||
|
text = `📊 <b>Расчёт:</b> ${calculator_name || 'Калькулятор'}\n\n`;
|
||||||
|
|
||||||
|
// Добавляем все пользовательские поля (кроме служебных)
|
||||||
|
const excludeFields = ['chat_id', 'calculator_id', 'calculator_name', 'telegram_message'];
|
||||||
|
Object.keys(req.body).forEach(key => {
|
||||||
|
if (!excludeFields.includes(key) && !key.startsWith('step_') && !key.startsWith('subtotal_') && !key.startsWith('additional_')) {
|
||||||
|
const value = req.body[key];
|
||||||
|
if (value !== undefined && value !== '' && value !== null) {
|
||||||
|
text += `${key}: ${value}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем результаты расчетов
|
||||||
|
const steps = {};
|
||||||
|
const subtotals = {};
|
||||||
|
const additional = {};
|
||||||
|
|
||||||
|
Object.keys(req.body).forEach(key => {
|
||||||
|
if (key.startsWith('step_')) {
|
||||||
|
steps[key.replace('step_', '')] = req.body[key];
|
||||||
|
} else if (key.startsWith('subtotal_')) {
|
||||||
|
subtotals[key.replace('subtotal_', '')] = req.body[key];
|
||||||
|
} else if (key.startsWith('additional_')) {
|
||||||
|
additional[key.replace('additional_', '')] = req.body[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(steps).length > 0) {
|
||||||
|
text += '\n<b>Шаги расчёта:</b>\n';
|
||||||
|
Object.keys(steps).forEach(key => {
|
||||||
|
text += ` ${key}: ${Number(steps[key]).toFixed(2)} ₽\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(subtotals).length > 0) {
|
||||||
|
text += '\n<b>Подитоги:</b>\n';
|
||||||
|
Object.keys(subtotals).forEach(key => {
|
||||||
|
text += ` ${key}: ${Number(subtotals[key]).toFixed(2)} ₽\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(additional).length > 0) {
|
||||||
|
text += '\n<b>Дополнительно:</b>\n';
|
||||||
|
Object.keys(additional).forEach(key => {
|
||||||
|
text += ` ${key}: ${Number(additional[key]).toFixed(2)} ₽\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Соберём сообщение так, чтобы в чат пришло всё, что ввели:
|
|
||||||
// 1. Название мыла
|
|
||||||
// 2. Вес и цена основы
|
|
||||||
// 3. Отдушка
|
|
||||||
// 4. Пигмент
|
|
||||||
// 5. Форма
|
|
||||||
// 6. Упаковка
|
|
||||||
// 7. Наценка
|
|
||||||
// 8. Итоги
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
|
||||||
|
|
||||||
text += `⚖️ <b>Вес мыла:</b> ${weight} г\n\n`;
|
|
||||||
// text += `🔹 <b>Цена за 1 кг основы:</b> ${basePrice} ₽/кг\n\n`;
|
|
||||||
|
|
||||||
// text += `🔹 <b>Отдушка:</b> ${aromaWeight} г по ${aromaPrice} ₽/фасовка\n`;
|
|
||||||
// text += `🔹 <b>Пигмент:</b> ${pigmentWeight} г по ${pigmentPrice} ₽/фасовка\n\n`;
|
|
||||||
|
|
||||||
// text += `🔹 <b>Цена формы:</b> ${moldPrice} ₽\n\n`;
|
|
||||||
|
|
||||||
text += `📦 <b>Упаковка:</b>\n`;
|
|
||||||
text += ` 📥 Пакет/коробка: ${box} ₽\n`;
|
|
||||||
text += ` 🌾 Наполнитель: ${filler} ₽\n`;
|
|
||||||
text += ` 🎀 Лента: ${ribbon} ₽\n`;
|
|
||||||
text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`;
|
|
||||||
|
|
||||||
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
|
||||||
|
|
||||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
|
||||||
text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
|
||||||
text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
|
||||||
text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
|
||||||
|
|
||||||
|
|
||||||
// Если пользователь прикрепил фото (req.file), шлём sendPhoto
|
// Если пользователь прикрепил фото (req.file), шлём sendPhoto
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
const bufferStream = streamifier.createReadStream(req.file.buffer);
|
const bufferStream = streamifier.createReadStream(req.file.buffer);
|
||||||
@ -146,6 +154,7 @@ app.post(
|
|||||||
// 6) Команда /menu — отправляем inline-кнопку с chat_id
|
// 6) Команда /menu — отправляем inline-кнопку с chat_id
|
||||||
bot.setMyCommands([
|
bot.setMyCommands([
|
||||||
{ command: 'menu', description: 'Открыть калькулятор' },
|
{ command: 'menu', description: 'Открыть калькулятор' },
|
||||||
|
{ command: 'myid', description: 'Узнать мой chat_id' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
bot.onText(/\/menu/, (msg) => {
|
bot.onText(/\/menu/, (msg) => {
|
||||||
@ -174,6 +183,20 @@ bot.onText(/\/menu/, (msg) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Команда для получения chat_id
|
||||||
|
bot.onText(/\/myid/, (msg) => {
|
||||||
|
try {
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
bot.sendMessage(
|
||||||
|
chatId,
|
||||||
|
`Ваш chat_id: <code>${chatId}</code>\n\nВы можете открыть калькулятор напрямую по ссылке:\n${WEBAPP_BASE_URL}/?chat_id=${chatId}`,
|
||||||
|
{ parse_mode: 'HTML' }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка в обработчике /myid:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 7) Ловим ошибки polling-а и логируем детали
|
// 7) Ловим ошибки polling-а и логируем детали
|
||||||
bot.on('polling_error', (err) => {
|
bot.on('polling_error', (err) => {
|
||||||
console.error('Polling error:', err);
|
console.error('Polling error:', err);
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import SoapCalculator from "@/components/SoapCalculator";
|
import CalculatorMenu from "@/components/CalculatorMenu";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<SoapCalculator />
|
<div className="min-h-screen py-8">
|
||||||
|
<CalculatorMenu />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
73
frontend/calculators/candles/calc.ts
Normal file
73
frontend/calculators/candles/calc.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// Функции расчета для калькулятора свечей
|
||||||
|
|
||||||
|
export function calculateCandleStep(
|
||||||
|
stepId: string,
|
||||||
|
values: Record<string, number>
|
||||||
|
): number {
|
||||||
|
const {
|
||||||
|
waxWeight = 0,
|
||||||
|
waxPrice = 0,
|
||||||
|
wickCount = 0,
|
||||||
|
wickPrice = 0,
|
||||||
|
fragrancePrice = 0,
|
||||||
|
fragranceWeight = 0,
|
||||||
|
dyePrice = 0,
|
||||||
|
dyeWeight = 0,
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
switch (stepId) {
|
||||||
|
case 'wax':
|
||||||
|
if (waxWeight <= 0 || waxPrice <= 0) return 0;
|
||||||
|
return (waxWeight / 1000) * waxPrice;
|
||||||
|
|
||||||
|
case 'wick':
|
||||||
|
if (wickCount <= 0 || wickPrice <= 0) return 0;
|
||||||
|
return wickCount * wickPrice;
|
||||||
|
|
||||||
|
case 'fragrance':
|
||||||
|
if (waxWeight <= 0 || fragranceWeight <= 0 || fragrancePrice <= 0) return 0;
|
||||||
|
// 10% отдушки от веса воска
|
||||||
|
return ((waxWeight * 0.10) / fragranceWeight) * fragrancePrice;
|
||||||
|
|
||||||
|
case 'dye':
|
||||||
|
if (waxWeight <= 0 || dyeWeight <= 0 || dyePrice <= 0) return 0;
|
||||||
|
// 1% красителя от веса воска
|
||||||
|
return ((waxWeight * 0.01) / dyeWeight) * dyePrice;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateCandleSubtotal(
|
||||||
|
subtotalId: string,
|
||||||
|
values: Record<string, number>,
|
||||||
|
steps: Record<string, number>
|
||||||
|
): number {
|
||||||
|
switch (subtotalId) {
|
||||||
|
case 'operational':
|
||||||
|
const subtotal =
|
||||||
|
(steps.wax || 0) +
|
||||||
|
(steps.wick || 0) +
|
||||||
|
(steps.fragrance || 0) +
|
||||||
|
(steps.dye || 0);
|
||||||
|
return subtotal * 0.05;
|
||||||
|
|
||||||
|
case 'total':
|
||||||
|
return (
|
||||||
|
(steps.wax || 0) +
|
||||||
|
(steps.wick || 0) +
|
||||||
|
(steps.fragrance || 0) +
|
||||||
|
(steps.dye || 0) +
|
||||||
|
(steps.operational || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round(val: number): number {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
246
frontend/calculators/candles/config.ts
Normal file
246
frontend/calculators/candles/config.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
// Конфигурация калькулятора свечей
|
||||||
|
import { CalculatorConfig } from '@/lib/calculator-types';
|
||||||
|
import { calculateCandleStep, calculateCandleSubtotal, round } from './calc';
|
||||||
|
|
||||||
|
export const candlesCalculatorConfig: CalculatorConfig = {
|
||||||
|
id: 'candles',
|
||||||
|
name: 'Калькулятор свечей',
|
||||||
|
description: 'Расчет себестоимости свечей ручной работы',
|
||||||
|
icon: '🕯️',
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'candleName',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Название свечи',
|
||||||
|
placeholder: 'Введите название',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo',
|
||||||
|
type: 'file',
|
||||||
|
label: 'Фото свечи (необязательно)',
|
||||||
|
accept: 'image/*',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waxWeight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Вес воска, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'wax',
|
||||||
|
showStepAfter: 'wax',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waxPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена воска за 1 кг, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'wax',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wickCount',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Количество фитилей',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'wick',
|
||||||
|
showStepAfter: 'wick',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wickPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена одного фитиля, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'wick',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fragrancePrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена отдушки, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'fragrance',
|
||||||
|
showStepAfter: 'fragrance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fragranceWeight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Фасовка отдушки, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'fragrance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dyePrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена красителя, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'dye',
|
||||||
|
showStepAfter: 'dye',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dyeWeight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Фасовка красителя, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'dye',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moldPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Стоимость формы/банки, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
groupName: 'mold',
|
||||||
|
showStepAfter: 'mold',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'packaging',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Упаковка, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
groupName: 'packaging',
|
||||||
|
showStepAfter: 'packaging',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'markup',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Наценка, %',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
calculationSteps: [
|
||||||
|
{
|
||||||
|
id: 'wax',
|
||||||
|
name: 'Себестоимость воска',
|
||||||
|
formula: (values) => round(calculateCandleStep('wax', values)),
|
||||||
|
formulaDescription: '(вес_воска / 1000) * цена_воска',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wick',
|
||||||
|
name: 'Себестоимость фитилей',
|
||||||
|
formula: (values) => round(calculateCandleStep('wick', values)),
|
||||||
|
formulaDescription: 'количество_фитилей * цена_фитиля',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fragrance',
|
||||||
|
name: 'Себестоимость отдушки (10 %)',
|
||||||
|
formula: (values) => round(calculateCandleStep('fragrance', values)),
|
||||||
|
formulaDescription: '((вес_воска * 0.10) / фасовка_отдушки) * цена_отдушки',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dye',
|
||||||
|
name: 'Себестоимость красителя (1 %)',
|
||||||
|
formula: (values) => round(calculateCandleStep('dye', values)),
|
||||||
|
formulaDescription: '((вес_воска * 0.01) / фасовка_красителя) * цена_красителя',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mold',
|
||||||
|
name: 'Стоимость формы/банки',
|
||||||
|
formula: (values) => {
|
||||||
|
const moldPrice = values.moldPrice || 0;
|
||||||
|
// Предположим, форма рассчитана на 100 использований
|
||||||
|
return round(moldPrice / 100);
|
||||||
|
},
|
||||||
|
formulaDescription: 'стоимость_формы / 100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'packaging',
|
||||||
|
name: 'Стоимость упаковки',
|
||||||
|
formula: (values) => {
|
||||||
|
const packaging = values.packaging || 0;
|
||||||
|
return round(packaging);
|
||||||
|
},
|
||||||
|
formulaDescription: 'стоимость_упаковки',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
subtotals: [
|
||||||
|
{
|
||||||
|
id: 'operational',
|
||||||
|
name: 'Операционные расходы (5 %)',
|
||||||
|
formula: (values, steps) => round(calculateCandleSubtotal('operational', values, steps)),
|
||||||
|
formulaDescription: '(воск + фитили + отдушка + краситель + форма + упаковка) * 0.05',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
name: 'Итого себестоимость',
|
||||||
|
formula: (values, steps) => round(calculateCandleSubtotal('total', values, steps)),
|
||||||
|
highlight: true,
|
||||||
|
formulaDescription: 'воск + фитили + отдушка + краситель + форма + упаковка + операционные',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
additionalCalculations: [
|
||||||
|
{
|
||||||
|
id: 'finalPrice',
|
||||||
|
name: 'Итоговая цена с наценкой',
|
||||||
|
formula: (values, steps, subtotals) => {
|
||||||
|
const total = subtotals.total || 0;
|
||||||
|
const markup = values.markup || 0;
|
||||||
|
return round(total * (1 + markup / 100));
|
||||||
|
},
|
||||||
|
formulaDescription: 'итого_себестоимость * (1 + наценка / 100)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pricePer100g',
|
||||||
|
name: 'Цена за 100 г',
|
||||||
|
formula: (values, steps, subtotals, additional) => {
|
||||||
|
const weight = values.waxWeight || 0;
|
||||||
|
const finalPrice = additional?.finalPrice || 0;
|
||||||
|
if (weight > 0) {
|
||||||
|
return round((finalPrice / weight) * 100);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
formulaDescription: '(итоговая_цена / вес_воска) * 100',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||||
|
const candleName = values.candleName || 'Без названия';
|
||||||
|
const waxWeight = values.waxWeight || 0;
|
||||||
|
const wickCount = values.wickCount || 0;
|
||||||
|
const packaging = values.packaging || 0;
|
||||||
|
const markup = values.markup || 0;
|
||||||
|
const totalCost = subtotals.total || 0;
|
||||||
|
const finalPrice = additional?.finalPrice || 0;
|
||||||
|
const pricePer100g = additional?.pricePer100g || 0;
|
||||||
|
|
||||||
|
let text = `🕯️ <b>Расчёт свечи:</b> <i>${candleName}</i>\n\n`;
|
||||||
|
text += `⚖️ <b>Вес воска:</b> ${waxWeight} г\n`;
|
||||||
|
text += `🕯️ <b>Количество фитилей:</b> ${wickCount} шт\n`;
|
||||||
|
text += `📦 <b>Упаковка:</b> ${packaging} ₽\n\n`;
|
||||||
|
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||||
|
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||||
|
text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
|
||||||
|
text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`;
|
||||||
|
text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`;
|
||||||
|
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
79
frontend/calculators/soap/calc.ts
Normal file
79
frontend/calculators/soap/calc.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// Функции расчета для калькулятора мыла
|
||||||
|
|
||||||
|
export function calculateSoapStep(
|
||||||
|
stepId: string,
|
||||||
|
values: Record<string, number>
|
||||||
|
): number {
|
||||||
|
const {
|
||||||
|
weight = 0,
|
||||||
|
basePrice = 0,
|
||||||
|
aromaPrice = 0,
|
||||||
|
aromaWeight = 0,
|
||||||
|
pigmentPrice = 0,
|
||||||
|
pigmentWeight = 0,
|
||||||
|
moldPrice = 0,
|
||||||
|
box = 0,
|
||||||
|
filler = 0,
|
||||||
|
ribbon = 0,
|
||||||
|
label = 0,
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
switch (stepId) {
|
||||||
|
case 'base':
|
||||||
|
if (weight <= 0 || basePrice <= 0) return 0;
|
||||||
|
return (weight / 1000) * basePrice;
|
||||||
|
|
||||||
|
case 'aroma':
|
||||||
|
if (weight <= 0 || aromaWeight <= 0 || aromaPrice <= 0) return 0;
|
||||||
|
return ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||||
|
|
||||||
|
case 'pigment':
|
||||||
|
if (weight <= 0 || pigmentWeight <= 0 || pigmentPrice <= 0) return 0;
|
||||||
|
return ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||||
|
|
||||||
|
case 'mold':
|
||||||
|
if (moldPrice <= 0) return 0;
|
||||||
|
return moldPrice / 100;
|
||||||
|
|
||||||
|
case 'packaging':
|
||||||
|
return (box || 0) + (filler || 0) + (ribbon || 0) + (label || 0);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateSoapSubtotal(
|
||||||
|
subtotalId: string,
|
||||||
|
values: Record<string, number>,
|
||||||
|
steps: Record<string, number>
|
||||||
|
): number {
|
||||||
|
switch (subtotalId) {
|
||||||
|
case 'operational':
|
||||||
|
const subtotal =
|
||||||
|
(steps.base || 0) +
|
||||||
|
(steps.aroma || 0) +
|
||||||
|
(steps.pigment || 0) +
|
||||||
|
(steps.mold || 0) +
|
||||||
|
(steps.packaging || 0);
|
||||||
|
return subtotal * 0.05;
|
||||||
|
|
||||||
|
case 'total':
|
||||||
|
return (
|
||||||
|
(steps.base || 0) +
|
||||||
|
(steps.aroma || 0) +
|
||||||
|
(steps.pigment || 0) +
|
||||||
|
(steps.mold || 0) +
|
||||||
|
(steps.packaging || 0) +
|
||||||
|
(steps.operational || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round(val: number): number {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
246
frontend/calculators/soap/config.ts
Normal file
246
frontend/calculators/soap/config.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
// Конфигурация калькулятора мыла
|
||||||
|
import { CalculatorConfig } from '@/lib/calculator-types';
|
||||||
|
import { calculateSoapStep, calculateSoapSubtotal, round } from './calc';
|
||||||
|
|
||||||
|
export const soapCalculatorConfig: CalculatorConfig = {
|
||||||
|
id: 'soap',
|
||||||
|
name: 'Калькулятор мыла',
|
||||||
|
description: 'Расчет себестоимости мыла ручной работы',
|
||||||
|
icon: '🧼',
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'soapName',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Название мыла',
|
||||||
|
placeholder: 'Введите название',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo',
|
||||||
|
type: 'file',
|
||||||
|
label: 'Фото мыла (необязательно)',
|
||||||
|
accept: 'image/*',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Вес мыла, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'base',
|
||||||
|
showStepAfter: 'base',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'basePrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена основы, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'base',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aromaPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена отдушки, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'aroma',
|
||||||
|
showStepAfter: 'aroma',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aromaWeight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Фасовка отдушки, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'aroma',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pigmentPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена пигмента, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'pigment',
|
||||||
|
showStepAfter: 'pigment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pigmentWeight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Фасовка пигмента, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'pigment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moldPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена формы, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
groupName: 'mold',
|
||||||
|
showStepAfter: 'mold',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'box',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Пакет/коробка, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'packaging',
|
||||||
|
showStepAfter: 'packaging',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filler',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Наполнитель, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'packaging',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ribbon',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Лента, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'packaging',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'label',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Наклейка, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
groupName: 'packaging',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'markup',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Наценка, %',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
calculationSteps: [
|
||||||
|
{
|
||||||
|
id: 'base',
|
||||||
|
name: 'Себестоимость основы',
|
||||||
|
formula: (values) => round(calculateSoapStep('base', values)),
|
||||||
|
formulaDescription: '(вес / 1000) * цена_основы',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aroma',
|
||||||
|
name: 'Себестоимость отдушки (1 %)',
|
||||||
|
formula: (values) => round(calculateSoapStep('aroma', values)),
|
||||||
|
formulaDescription: '((вес * 0.01) / фасовка_отдушки) * цена_отдушки',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pigment',
|
||||||
|
name: 'Себестоимость пигмента (0.5 %)',
|
||||||
|
formula: (values) => round(calculateSoapStep('pigment', values)),
|
||||||
|
formulaDescription: '((вес * 0.005) / фасовка_пигмента) * цена_пигмента',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mold',
|
||||||
|
name: 'Себестоимость формы',
|
||||||
|
formula: (values) => round(calculateSoapStep('mold', values)),
|
||||||
|
formulaDescription: 'цена_формы / 100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'packaging',
|
||||||
|
name: 'Стоимость упаковки',
|
||||||
|
formula: (values) => round(calculateSoapStep('packaging', values)),
|
||||||
|
formulaDescription: 'пакет + наполнитель + лента + наклейка',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
subtotals: [
|
||||||
|
{
|
||||||
|
id: 'operational',
|
||||||
|
name: 'Операционные расходы (5 %)',
|
||||||
|
formula: (values, steps) => round(calculateSoapSubtotal('operational', values, steps)),
|
||||||
|
formulaDescription: '(основа + отдушка + пигмент + форма + упаковка) * 0.05',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
name: 'Итого себестоимость',
|
||||||
|
formula: (values, steps) => round(calculateSoapSubtotal('total', values, steps)),
|
||||||
|
highlight: true,
|
||||||
|
formulaDescription: 'основа + отдушка + пигмент + форма + упаковка + операционные',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
additionalCalculations: [
|
||||||
|
{
|
||||||
|
id: 'finalPrice',
|
||||||
|
name: 'Итоговая цена с наценкой',
|
||||||
|
formula: (values, steps, subtotals) => {
|
||||||
|
const total = subtotals.total || 0;
|
||||||
|
const markup = values.markup || 0;
|
||||||
|
return round(total * (1 + markup / 100));
|
||||||
|
},
|
||||||
|
formulaDescription: 'итого_себестоимость * (1 + наценка / 100)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pricePer100g',
|
||||||
|
name: 'Цена за 100 г',
|
||||||
|
formula: (values, steps, subtotals, additional) => {
|
||||||
|
const weight = values.weight || 0;
|
||||||
|
const finalPrice = additional?.finalPrice || 0;
|
||||||
|
if (weight > 0) {
|
||||||
|
return round((finalPrice / weight) * 100);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
formulaDescription: '(итоговая_цена / вес) * 100',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||||
|
const soapName = values.soapName || 'Без названия';
|
||||||
|
const weight = values.weight || 0;
|
||||||
|
const box = values.box || 0;
|
||||||
|
const filler = values.filler || 0;
|
||||||
|
const ribbon = values.ribbon || 0;
|
||||||
|
const label = values.label || 0;
|
||||||
|
const markup = values.markup || 0;
|
||||||
|
const totalCost = subtotals.total || 0;
|
||||||
|
const finalPrice = additional?.finalPrice || 0;
|
||||||
|
const pricePer100g = additional?.pricePer100g || 0;
|
||||||
|
|
||||||
|
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
||||||
|
text += `⚖️ <b>Вес мыла:</b> ${weight} г\n\n`;
|
||||||
|
text += `📦 <b>Упаковка:</b>\n`;
|
||||||
|
text += ` 📥 Пакет/коробка: ${box} ₽\n`;
|
||||||
|
text += ` 🌾 Наполнитель: ${filler} ₽\n`;
|
||||||
|
text += ` 🎀 Лента: ${ribbon} ₽\n`;
|
||||||
|
text += ` 🏷️ Наклейка: ${label} ₽\n\n`;
|
||||||
|
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||||
|
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||||
|
text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
|
||||||
|
text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`;
|
||||||
|
text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`;
|
||||||
|
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
548
frontend/components/CalculatorEngine.tsx
Normal file
548
frontend/components/CalculatorEngine.tsx
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { CalculatorConfig, CalculatorValues, CalculationResults } from '@/lib/calculator-types';
|
||||||
|
import { getCalculator } from '@/lib/calculator-registry';
|
||||||
|
|
||||||
|
type CalculatorEngineProps = {
|
||||||
|
calculatorId: string;
|
||||||
|
chatId?: string | null;
|
||||||
|
onBack?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNum = (str: string): number => {
|
||||||
|
const n = parseFloat(str.replace(',', '.'));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputNumber = ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
gridCols = 2,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
gridCols?: 1 | 2;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={gridCols === 2 ? '' : 'col-span-2'}>
|
||||||
|
<div className="relative mt-6">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder=" "
|
||||||
|
className={`
|
||||||
|
peer
|
||||||
|
h-10 w-full
|
||||||
|
bg-gray-700
|
||||||
|
border-2 border-gray-600
|
||||||
|
rounded-md
|
||||||
|
text-gray-200
|
||||||
|
placeholder-transparent
|
||||||
|
pl-3
|
||||||
|
focus:outline-none focus:border-sky-500
|
||||||
|
appearance-none
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`
|
||||||
|
absolute left-3
|
||||||
|
-top-5
|
||||||
|
text-gray-400
|
||||||
|
transition-all
|
||||||
|
text-xs sm:text-sm md:text-base lg:text-lg
|
||||||
|
peer-placeholder-shown:top-2
|
||||||
|
peer-placeholder-shown:text-gray-500
|
||||||
|
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||||
|
peer-focus:-top-5
|
||||||
|
peer-focus:text-gray-200
|
||||||
|
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputText = ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="relative mt-6 col-span-2">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder=" "
|
||||||
|
className={`
|
||||||
|
peer
|
||||||
|
h-10 w-full
|
||||||
|
bg-gray-700
|
||||||
|
border-2 border-gray-600
|
||||||
|
rounded-md
|
||||||
|
text-gray-200
|
||||||
|
placeholder-transparent
|
||||||
|
pl-3
|
||||||
|
focus:outline-none focus:border-sky-500
|
||||||
|
appearance-none
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`
|
||||||
|
absolute left-3
|
||||||
|
-top-5
|
||||||
|
text-gray-400
|
||||||
|
transition-all
|
||||||
|
text-xs sm:text-sm md:text-base lg:text-lg
|
||||||
|
peer-placeholder-shown:top-2
|
||||||
|
peer-placeholder-shown:text-gray-500
|
||||||
|
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||||
|
peer-focus:-top-5
|
||||||
|
peer-focus:text-gray-200
|
||||||
|
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CostBlock = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
highlight = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) => {
|
||||||
|
const displayValue = isNaN(value) || !isFinite(value) ? 0 : value;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
p-2
|
||||||
|
${highlight ? 'bg-gray-600 font-semibold' : 'bg-gray-700'}
|
||||||
|
text-gray-200
|
||||||
|
rounded
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{title}: {displayValue.toFixed(1)} руб
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalculatorEngine({
|
||||||
|
calculatorId,
|
||||||
|
chatId: externalChatId,
|
||||||
|
onBack,
|
||||||
|
}: CalculatorEngineProps) {
|
||||||
|
const [config, setConfig] = useState<CalculatorConfig | null>(null);
|
||||||
|
const [values, setValues] = useState<CalculatorValues>({});
|
||||||
|
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||||
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Загружаем конфигурацию калькулятора
|
||||||
|
useEffect(() => {
|
||||||
|
const calculatorConfig = getCalculator(calculatorId);
|
||||||
|
if (calculatorConfig) {
|
||||||
|
setConfig(calculatorConfig);
|
||||||
|
// Инициализируем значения по умолчанию
|
||||||
|
const initialValues: CalculatorValues = {};
|
||||||
|
calculatorConfig.fields.forEach((field) => {
|
||||||
|
initialValues[field.id] = field.defaultValue || '';
|
||||||
|
});
|
||||||
|
setValues(initialValues);
|
||||||
|
}
|
||||||
|
}, [calculatorId]);
|
||||||
|
|
||||||
|
// Получаем chat_id из URL или пропсов
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalChatId) {
|
||||||
|
setChatId(externalChatId);
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const id = params.get('chat_id');
|
||||||
|
if (id) {
|
||||||
|
setChatId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [externalChatId]);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg">
|
||||||
|
<p>Калькулятор не найден</p>
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="mt-4 px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white rounded"
|
||||||
|
>
|
||||||
|
Назад к меню
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем все значения как числа
|
||||||
|
const numValues: Record<string, number> = {};
|
||||||
|
Object.keys(values).forEach((key) => {
|
||||||
|
if (config.fields.find((f) => f.id === key && f.type !== 'file' && f.type !== 'text')) {
|
||||||
|
numValues[key] = toNum(values[key]);
|
||||||
|
} else if (config.fields.find((f) => f.id === key && f.type === 'text')) {
|
||||||
|
numValues[key] = values[key] as any; // Для текстовых полей сохраняем строку
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Выполняем расчеты
|
||||||
|
const results: CalculationResults = {
|
||||||
|
steps: {},
|
||||||
|
subtotals: {},
|
||||||
|
additional: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Шаги расчета
|
||||||
|
config.calculationSteps.forEach((step) => {
|
||||||
|
results.steps[step.id] = step.formula(numValues);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подитоги
|
||||||
|
config.subtotals.forEach((subtotal) => {
|
||||||
|
results.subtotals[subtotal.id] = subtotal.formula(numValues, results.steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Дополнительные расчеты (нужно делать последовательно, так как некоторые зависят от других)
|
||||||
|
if (config.additionalCalculations) {
|
||||||
|
config.additionalCalculations.forEach((calc) => {
|
||||||
|
results.additional![calc.id] = calc.formula(numValues, results.steps, results.subtotals, results.additional);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFieldChange = (fieldId: string, newValue: string) => {
|
||||||
|
setValues((prev) => ({ ...prev, [fieldId]: newValue }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setPhotoFile(e.target.files[0]);
|
||||||
|
} else {
|
||||||
|
setPhotoFile(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!chatId) {
|
||||||
|
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('chat_id', chatId);
|
||||||
|
formData.append('calculator_id', config.id);
|
||||||
|
formData.append('calculator_name', config.name);
|
||||||
|
|
||||||
|
// Добавляем все поля
|
||||||
|
config.fields.forEach((field) => {
|
||||||
|
if (field.type !== 'file') {
|
||||||
|
const value = values[field.id] || '';
|
||||||
|
if (field.type === 'number') {
|
||||||
|
formData.append(field.id, toNum(value).toString());
|
||||||
|
} else {
|
||||||
|
formData.append(field.id, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем результаты расчетов
|
||||||
|
Object.keys(results.steps).forEach((key) => {
|
||||||
|
formData.append(`step_${key}`, results.steps[key].toString());
|
||||||
|
});
|
||||||
|
Object.keys(results.subtotals).forEach((key) => {
|
||||||
|
formData.append(`subtotal_${key}`, results.subtotals[key].toString());
|
||||||
|
});
|
||||||
|
if (results.additional) {
|
||||||
|
Object.keys(results.additional).forEach((key) => {
|
||||||
|
formData.append(`additional_${key}`, results.additional![key].toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем фото если есть
|
||||||
|
if (photoFile) {
|
||||||
|
formData.append('photo', photoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем данные для форматирования сообщения
|
||||||
|
const allValues: Record<string, string | number> = {};
|
||||||
|
Object.keys(values).forEach((key) => {
|
||||||
|
const field = config.fields.find((f) => f.id === key);
|
||||||
|
if (field && field.type === 'number') {
|
||||||
|
allValues[key] = toNum(values[key]);
|
||||||
|
} else {
|
||||||
|
allValues[key] = values[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Форматируем сообщение для Telegram
|
||||||
|
const telegramMessage = config.formatTelegramMessage
|
||||||
|
? config.formatTelegramMessage(allValues, results.steps, results.subtotals, results.additional || {})
|
||||||
|
: JSON.stringify({ values, results }, null, 2);
|
||||||
|
|
||||||
|
formData.append('telegram_message', telegramMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Для локальной разработки используем localhost, для продакшена - api-dosoap.duckdns.org
|
||||||
|
const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost';
|
||||||
|
const apiUrl = isLocalhost
|
||||||
|
? 'http://localhost:3001/api/submit'
|
||||||
|
: 'https://api-dosoap.duckdns.org/api/submit';
|
||||||
|
|
||||||
|
const res = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert('✅ Расчёт успешно отправлен в Telegram!');
|
||||||
|
// Сброс формы
|
||||||
|
const resetValues: CalculatorValues = {};
|
||||||
|
config.fields.forEach((field) => {
|
||||||
|
resetValues[field.id] = field.defaultValue || '';
|
||||||
|
});
|
||||||
|
setValues(resetValues);
|
||||||
|
setPhotoFile(null);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
const text = await res.text();
|
||||||
|
alert(`Ошибка при отправке: ${text}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Ошибка сети при отправке расчёта');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Находим поле для фото
|
||||||
|
const photoField = config.fields.find((f) => f.type === 'file');
|
||||||
|
const textFields = config.fields.filter((f) => f.type === 'text');
|
||||||
|
|
||||||
|
// Группируем числовые поля по группам
|
||||||
|
const numberFields = config.fields.filter((f) => f.type === 'number');
|
||||||
|
const fieldsByGroup: Record<string, typeof numberFields> = {};
|
||||||
|
const ungroupedFields: typeof numberFields = [];
|
||||||
|
|
||||||
|
numberFields.forEach((field) => {
|
||||||
|
if (field.groupName) {
|
||||||
|
if (!fieldsByGroup[field.groupName]) {
|
||||||
|
fieldsByGroup[field.groupName] = [];
|
||||||
|
}
|
||||||
|
fieldsByGroup[field.groupName].push(field);
|
||||||
|
} else {
|
||||||
|
ungroupedFields.push(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Определяем порядок групп на основе порядка полей в конфигурации
|
||||||
|
const groupOrder: string[] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
|
||||||
|
numberFields.forEach((field) => {
|
||||||
|
if (field.groupName && !seenGroups.has(field.groupName)) {
|
||||||
|
groupOrder.push(field.groupName);
|
||||||
|
seenGroups.add(field.groupName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем группы, которые не были найдены (на всякий случай)
|
||||||
|
Object.keys(fieldsByGroup).forEach((groupName) => {
|
||||||
|
if (!seenGroups.has(groupName)) {
|
||||||
|
groupOrder.push(groupName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="
|
||||||
|
max-w-xl mx-auto p-6 space-y-6
|
||||||
|
bg-gray-800 text-gray-200
|
||||||
|
rounded-lg shadow-lg
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Кнопка назад */}
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="mb-4 px-4 py-2 bg-gray-600 hover:bg-gray-500 text-gray-200 rounded"
|
||||||
|
>
|
||||||
|
← Назад к меню
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Логотип */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Image
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="Logo"
|
||||||
|
width={150}
|
||||||
|
height={50}
|
||||||
|
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chatId === null && (
|
||||||
|
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
|
||||||
|
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Текстовые поля */}
|
||||||
|
{textFields.map((field) => (
|
||||||
|
<InputText
|
||||||
|
key={field.id}
|
||||||
|
id={field.id}
|
||||||
|
label={field.label}
|
||||||
|
value={values[field.id] || ''}
|
||||||
|
onChange={(value) => handleFieldChange(field.id, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Поле для фото */}
|
||||||
|
{photoField && (
|
||||||
|
<>
|
||||||
|
<label className="flex flex-col gap-1 col-span-2">
|
||||||
|
<span className="text-gray-300">{photoField.label}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={photoField.accept || 'image/*'}
|
||||||
|
onChange={handlePhotoChange}
|
||||||
|
className="
|
||||||
|
bg-gray-700
|
||||||
|
text-gray-200
|
||||||
|
rounded-md
|
||||||
|
border border-gray-600
|
||||||
|
p-2
|
||||||
|
focus:outline-none focus:border-sky-500
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{photoFile && (
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(photoFile)}
|
||||||
|
alt="Предпросмотр"
|
||||||
|
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Группированные поля с расчетами между ними */}
|
||||||
|
{groupOrder.map((groupName) => {
|
||||||
|
const groupFields = fieldsByGroup[groupName] || [];
|
||||||
|
const firstField = groupFields[0];
|
||||||
|
const showStepId = firstField?.showStepAfter;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={groupName} className="space-y-4">
|
||||||
|
{/* Поля группы */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{groupFields.map((field) => (
|
||||||
|
<InputNumber
|
||||||
|
key={field.id}
|
||||||
|
id={field.id}
|
||||||
|
label={field.label}
|
||||||
|
value={values[field.id] || ''}
|
||||||
|
onChange={(value) => handleFieldChange(field.id, value)}
|
||||||
|
gridCols={field.gridCols}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Блок расчета после группы */}
|
||||||
|
{showStepId && (
|
||||||
|
<CostBlock
|
||||||
|
title={config.calculationSteps.find((s) => s.id === showStepId)?.name || ''}
|
||||||
|
value={results.steps[showStepId] || 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Несгруппированные поля */}
|
||||||
|
{ungroupedFields.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{ungroupedFields.map((field) => (
|
||||||
|
<InputNumber
|
||||||
|
key={field.id}
|
||||||
|
id={field.id}
|
||||||
|
label={field.label}
|
||||||
|
value={values[field.id] || ''}
|
||||||
|
onChange={(value) => handleFieldChange(field.id, value)}
|
||||||
|
gridCols={field.gridCols}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Подитоги */}
|
||||||
|
{config.subtotals.map((subtotal) => (
|
||||||
|
<CostBlock
|
||||||
|
key={subtotal.id}
|
||||||
|
title={subtotal.name}
|
||||||
|
value={results.subtotals[subtotal.id]}
|
||||||
|
highlight={subtotal.highlight}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Дополнительные расчеты */}
|
||||||
|
{config.additionalCalculations?.map((calc) => (
|
||||||
|
<CostBlock
|
||||||
|
key={calc.id}
|
||||||
|
title={calc.name}
|
||||||
|
value={results.additional![calc.id]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Кнопка отправки */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="
|
||||||
|
w-full
|
||||||
|
py-2
|
||||||
|
rounded-md
|
||||||
|
bg-sky-500 hover:bg-sky-600
|
||||||
|
text-gray-100 font-semibold
|
||||||
|
focus:outline-none focus:ring focus:ring-offset-2 focus:ring-sky-500 focus:ring-opacity-60
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Отправить расчёт в Telegram
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
108
frontend/components/CalculatorMenu.tsx
Normal file
108
frontend/components/CalculatorMenu.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { getAllCalculators } from '@/lib/calculator-registry';
|
||||||
|
import CalculatorEngine from './CalculatorEngine';
|
||||||
|
|
||||||
|
type CalculatorMenuProps = {
|
||||||
|
chatId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalculatorMenu({ chatId: externalChatId }: CalculatorMenuProps) {
|
||||||
|
const [selectedCalculator, setSelectedCalculator] = useState<string | null>(null);
|
||||||
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
|
const calculators = getAllCalculators();
|
||||||
|
|
||||||
|
// Получаем chat_id из URL или пропсов
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalChatId) {
|
||||||
|
setChatId(externalChatId);
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const id = params.get('chat_id');
|
||||||
|
if (id) {
|
||||||
|
setChatId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [externalChatId]);
|
||||||
|
|
||||||
|
// Если выбран калькулятор, показываем его
|
||||||
|
if (selectedCalculator) {
|
||||||
|
return (
|
||||||
|
<CalculatorEngine
|
||||||
|
calculatorId={selectedCalculator}
|
||||||
|
chatId={chatId}
|
||||||
|
onBack={() => setSelectedCalculator(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе показываем меню выбора
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg shadow-lg">
|
||||||
|
{/* Логотип */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<Image
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="Logo"
|
||||||
|
width={150}
|
||||||
|
height={50}
|
||||||
|
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chatId === null && (
|
||||||
|
<div className="bg-yellow-800 p-4 text-yellow-200 font-semibold rounded mb-6">
|
||||||
|
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-8">
|
||||||
|
Выберите калькулятор
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Список калькуляторов */}
|
||||||
|
{calculators.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
Нет доступных калькуляторов
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{calculators.map((calc) => (
|
||||||
|
<button
|
||||||
|
key={calc.id}
|
||||||
|
onClick={() => setSelectedCalculator(calc.id)}
|
||||||
|
className="
|
||||||
|
p-6
|
||||||
|
bg-gray-700
|
||||||
|
hover:bg-gray-600
|
||||||
|
rounded-lg
|
||||||
|
border-2 border-gray-600
|
||||||
|
hover:border-sky-500
|
||||||
|
transition-all
|
||||||
|
text-left
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-sky-500
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{calc.icon && <span className="text-3xl">{calc.icon}</span>}
|
||||||
|
<h2 className="text-xl font-semibold">{calc.name}</h2>
|
||||||
|
</div>
|
||||||
|
{calc.description && (
|
||||||
|
<p className="text-gray-400 text-sm">{calc.description}</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Подсказка */}
|
||||||
|
<div className="mt-8 text-center text-gray-400 text-sm">
|
||||||
|
Выберите калькулятор для начала расчёта
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,397 +0,0 @@
|
|||||||
// components/SoapCalculator.tsx
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { calculateTotal } from '@/lib/calc';
|
|
||||||
|
|
||||||
type InputNumberProps = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const InputNumber = ({ label, value, onChange }: InputNumberProps) => {
|
|
||||||
const id = label.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative mt-6">
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="number"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder=" "
|
|
||||||
className={`
|
|
||||||
peer
|
|
||||||
h-10 w-full
|
|
||||||
bg-gray-700
|
|
||||||
border-2 border-gray-600
|
|
||||||
rounded-md
|
|
||||||
text-gray-200
|
|
||||||
placeholder-transparent
|
|
||||||
pl-3
|
|
||||||
focus:outline-none focus:border-sky-500
|
|
||||||
appearance-none
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={id}
|
|
||||||
className={`
|
|
||||||
absolute left-3
|
|
||||||
-top-5
|
|
||||||
text-gray-400
|
|
||||||
transition-all
|
|
||||||
/* активное состояние: адаптивный размер шрифта */
|
|
||||||
text-xs sm:text-sm md:text-base lg:text-lg
|
|
||||||
/* когда поле пустое: слегка больше (помещается внутри) */
|
|
||||||
peer-placeholder-shown:top-2
|
|
||||||
peer-placeholder-shown:text-gray-500
|
|
||||||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
|
||||||
/* при фокусе или если есть содержимое: сжатый текст */
|
|
||||||
peer-focus:-top-5
|
|
||||||
peer-focus:text-gray-200
|
|
||||||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SoapCalculator() {
|
|
||||||
const [soapName, setSoapName] = useState('');
|
|
||||||
const [weight, setWeight] = useState('');
|
|
||||||
const [basePrice, setBasePrice] = useState('');
|
|
||||||
const [aromaPrice, setAromaPrice] = useState('');
|
|
||||||
const [aromaWeight, setAromaWeight] = useState('');
|
|
||||||
const [pigmentPrice, setPigmentPrice] = useState('');
|
|
||||||
const [pigmentWeight, setPigmentWeight] = useState('');
|
|
||||||
const [moldPrice, setMoldPrice] = useState('');
|
|
||||||
const [box, setBox] = useState('');
|
|
||||||
const [filler, setFiller] = useState('');
|
|
||||||
const [ribbon, setRibbon] = useState('');
|
|
||||||
const [labelValue, setLabelValue] = useState('');
|
|
||||||
const [markup, setMarkup] = useState('');
|
|
||||||
|
|
||||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const id = params.get('chat_id');
|
|
||||||
if (id) {
|
|
||||||
setChatId(id);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toNum = (str: string) => {
|
|
||||||
const n = parseFloat(str.replace(',', '.'));
|
|
||||||
return isNaN(n) ? 0 : n;
|
|
||||||
};
|
|
||||||
const weightNum = toNum(weight);
|
|
||||||
const basePriceNum = toNum(basePrice);
|
|
||||||
const aromaPriceNum = toNum(aromaPrice);
|
|
||||||
const aromaWeightNum = toNum(aromaWeight);
|
|
||||||
const pigmentPriceNum = toNum(pigmentPrice);
|
|
||||||
const pigmentWeightNum = toNum(pigmentWeight);
|
|
||||||
const moldPriceNum = toNum(moldPrice);
|
|
||||||
const boxNum = toNum(box);
|
|
||||||
const fillerNum = toNum(filler);
|
|
||||||
const ribbonNum = toNum(ribbon);
|
|
||||||
const labelNum = toNum(labelValue);
|
|
||||||
const markupNum = toNum(markup);
|
|
||||||
|
|
||||||
const result = calculateTotal({
|
|
||||||
weight: weightNum,
|
|
||||||
basePrice: basePriceNum,
|
|
||||||
aromaPrice: aromaPriceNum,
|
|
||||||
aromaWeight: aromaWeightNum,
|
|
||||||
pigmentPrice: pigmentPriceNum,
|
|
||||||
pigmentWeight: pigmentWeightNum,
|
|
||||||
moldPrice: moldPriceNum,
|
|
||||||
packaging: {
|
|
||||||
box: boxNum,
|
|
||||||
filler: fillerNum,
|
|
||||||
ribbon: ribbonNum,
|
|
||||||
label: labelNum,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalPrice = result.total * (1 + markupNum / 100);
|
|
||||||
const pricePer100g = weightNum > 0 ? (finalPrice / weightNum) * 100 : 0;
|
|
||||||
|
|
||||||
const handlePhotoChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files && e.target.files[0]) {
|
|
||||||
setPhotoFile(e.target.files[0]);
|
|
||||||
} else {
|
|
||||||
setPhotoFile(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!chatId) {
|
|
||||||
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('chat_id', chatId);
|
|
||||||
formData.append('soapName', soapName || '');
|
|
||||||
formData.append('weight', weightNum.toString());
|
|
||||||
formData.append('basePrice', basePriceNum.toString());
|
|
||||||
formData.append('aromaPrice', aromaPriceNum.toString());
|
|
||||||
formData.append('aromaWeight', aromaWeightNum.toString());
|
|
||||||
formData.append('pigmentPrice', pigmentPriceNum.toString());
|
|
||||||
formData.append('pigmentWeight', pigmentWeightNum.toString());
|
|
||||||
formData.append('moldPrice', moldPriceNum.toString());
|
|
||||||
formData.append('box', boxNum.toString());
|
|
||||||
formData.append('filler', fillerNum.toString());
|
|
||||||
formData.append('ribbon', ribbonNum.toString());
|
|
||||||
formData.append('labelValue', labelNum.toString());
|
|
||||||
formData.append('markup', markupNum.toString());
|
|
||||||
formData.append('totalCost', result.total.toString());
|
|
||||||
formData.append('finalPrice', finalPrice.toString());
|
|
||||||
formData.append('pricePer100g', pricePer100g.toString());
|
|
||||||
|
|
||||||
if (photoFile) {
|
|
||||||
formData.append('photo', photoFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://api-dosoap.duckdns.org/api/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
alert('✅ Расчёт успешно отправлен в Telegram!');
|
|
||||||
setSoapName('');
|
|
||||||
setWeight('');
|
|
||||||
setBasePrice('');
|
|
||||||
setAromaPrice('');
|
|
||||||
setAromaWeight('');
|
|
||||||
setPigmentPrice('');
|
|
||||||
setPigmentWeight('');
|
|
||||||
setMoldPrice('');
|
|
||||||
setBox('');
|
|
||||||
setFiller('');
|
|
||||||
setRibbon('');
|
|
||||||
setLabelValue('');
|
|
||||||
setMarkup('');
|
|
||||||
setPhotoFile(null);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
const text = await res.text();
|
|
||||||
alert(`Ошибка при отправке: ${text}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert('Ошибка сети при отправке расчёта');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="
|
|
||||||
max-w-xl mx-auto p-6 space-y-6
|
|
||||||
bg-gray-800 text-gray-200
|
|
||||||
rounded-lg shadow-lg
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{/* Центрированный адаптивный логотип */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Image
|
|
||||||
src="/logo.svg"
|
|
||||||
alt="Logo"
|
|
||||||
width={150}
|
|
||||||
height={50}
|
|
||||||
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{chatId === null && (
|
|
||||||
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
|
|
||||||
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Название мыла */}
|
|
||||||
<div className="relative mt-6">
|
|
||||||
<input
|
|
||||||
id="soap-name"
|
|
||||||
type="text"
|
|
||||||
value={soapName}
|
|
||||||
onChange={(e) => setSoapName(e.target.value)}
|
|
||||||
placeholder=" "
|
|
||||||
className={`
|
|
||||||
peer
|
|
||||||
h-10 w-full
|
|
||||||
bg-gray-700
|
|
||||||
border-2 border-gray-600
|
|
||||||
rounded-md
|
|
||||||
text-gray-200
|
|
||||||
placeholder-transparent
|
|
||||||
pl-3
|
|
||||||
focus:outline-none focus:border-sky-500
|
|
||||||
appearance-none
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="soap-name"
|
|
||||||
className={`
|
|
||||||
absolute left-3
|
|
||||||
-top-5
|
|
||||||
text-gray-400
|
|
||||||
transition-all
|
|
||||||
/* активное состояние: адаптивный размер шрифта */
|
|
||||||
text-xs sm:text-sm md:text-base lg:text-lg
|
|
||||||
/* когда поле пустое: немного крупнее, помещается внутри */
|
|
||||||
peer-placeholder-shown:top-2
|
|
||||||
peer-placeholder-shown:text-gray-500
|
|
||||||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
|
||||||
/* при фокусе/заполненном поле: адаптивный размер */
|
|
||||||
peer-focus:-top-5
|
|
||||||
peer-focus:text-gray-200
|
|
||||||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
Название мыла
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Фото мыла */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-gray-300">Фото мыла (необязательно)</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handlePhotoChange}
|
|
||||||
className="
|
|
||||||
bg-gray-700
|
|
||||||
text-gray-200
|
|
||||||
rounded-md
|
|
||||||
border border-gray-600
|
|
||||||
p-2
|
|
||||||
focus:outline-none focus:border-sky-500
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{photoFile && (
|
|
||||||
<img
|
|
||||||
src={URL.createObjectURL(photoFile)}
|
|
||||||
alt="Предпросмотр мыла"
|
|
||||||
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Блок «Основа» */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<InputNumber label="Вес мыла, г" value={weight} onChange={setWeight} />
|
|
||||||
<InputNumber
|
|
||||||
label="Цена основы, руб"
|
|
||||||
value={basePrice}
|
|
||||||
onChange={setBasePrice}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CostBlock title="Себестоимость основы" value={result.base} />
|
|
||||||
|
|
||||||
{/* Блок «Отдушка» */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<InputNumber
|
|
||||||
label="Цена отдушки, руб"
|
|
||||||
value={aromaPrice}
|
|
||||||
onChange={setAromaPrice}
|
|
||||||
/>
|
|
||||||
<InputNumber
|
|
||||||
label="Фасовка отдушки, г"
|
|
||||||
value={aromaWeight}
|
|
||||||
onChange={setAromaWeight}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CostBlock title="Себестоимость отдушки (1 %)" value={result.aroma} />
|
|
||||||
|
|
||||||
{/* Блок «Пигмент» */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<InputNumber
|
|
||||||
label="Цена пигмента, руб"
|
|
||||||
value={pigmentPrice}
|
|
||||||
onChange={setPigmentPrice}
|
|
||||||
/>
|
|
||||||
<InputNumber
|
|
||||||
label="Фасовка пигмента, г"
|
|
||||||
value={pigmentWeight}
|
|
||||||
onChange={setPigmentWeight}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CostBlock title="Себестоимость пигмента (0.5 %)" value={result.pigment} />
|
|
||||||
|
|
||||||
{/* Блок «Форма» */}
|
|
||||||
<InputNumber
|
|
||||||
label="Цена формы, руб"
|
|
||||||
value={moldPrice}
|
|
||||||
onChange={setMoldPrice}
|
|
||||||
/>
|
|
||||||
<CostBlock title="Себестоимость формы" value={result.mold} />
|
|
||||||
|
|
||||||
{/* Блок «Упаковка» */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<InputNumber label="Пакет/коробка, руб" value={box} onChange={setBox} />
|
|
||||||
<InputNumber label="Наполнитель, руб" value={filler} onChange={setFiller} />
|
|
||||||
<InputNumber label="Лента, руб" value={ribbon} onChange={setRibbon} />
|
|
||||||
<InputNumber
|
|
||||||
label="Наклейка, руб"
|
|
||||||
value={labelValue}
|
|
||||||
onChange={setLabelValue}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CostBlock title="Стоимость упаковки" value={result.packaging} />
|
|
||||||
<CostBlock title="Операционные расходы (5 %)" value={result.operational} />
|
|
||||||
<CostBlock title="Итого себестоимость" value={result.total} highlight />
|
|
||||||
|
|
||||||
{/* Блок «Наценка и цена 100 г» */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<InputNumber label="Наценка, %" value={markup} onChange={setMarkup} />
|
|
||||||
</div>
|
|
||||||
<CostBlock title="Итоговая цена с наценкой" value={finalPrice} />
|
|
||||||
<CostBlock title="Цена за 100 г" value={pricePer100g} />
|
|
||||||
|
|
||||||
{/* Кнопка «Отправить расчёт» */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="
|
|
||||||
w-full
|
|
||||||
py-2
|
|
||||||
rounded-md
|
|
||||||
bg-sky-500 hover:bg-sky-600
|
|
||||||
text-gray-100 font-semibold
|
|
||||||
focus:outline-none focus:ring focus:ring-offset-2 focus:ring-sky-500 focus:ring-opacity-60
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Отправить расчёт в Telegram
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CostBlockProps = {
|
|
||||||
title: string;
|
|
||||||
value: number;
|
|
||||||
highlight?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CostBlock = ({ title, value, highlight = false }: CostBlockProps) => (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
p-2
|
|
||||||
${highlight ? 'bg-gray-600 font-semibold' : 'bg-gray-700'}
|
|
||||||
text-gray-200
|
|
||||||
rounded
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{title}: {value.toFixed(1)} руб
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
386
frontend/docs/calculator-creation-guide.md
Normal file
386
frontend/docs/calculator-creation-guide.md
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
# Руководство по созданию новых калькуляторов
|
||||||
|
|
||||||
|
Это руководство поможет вам быстро создать новый калькулятор в модульной системе.
|
||||||
|
|
||||||
|
## Структура модуля калькулятора
|
||||||
|
|
||||||
|
Каждый калькулятор состоит из двух файлов в папке `frontend/calculators/[название]/`:
|
||||||
|
|
||||||
|
- `config.ts` — конфигурация калькулятора (поля, формулы, подитоги)
|
||||||
|
- `calc.ts` — функции расчета (опционально, если нужны сложные вычисления)
|
||||||
|
|
||||||
|
## Шаг 1: Создание папки модуля
|
||||||
|
|
||||||
|
Создайте папку для вашего калькулятора:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/calculators/candles/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 2: Создание файла calc.ts (если нужны функции расчета)
|
||||||
|
|
||||||
|
Если у вас простые формулы, можно обойтись без этого файла и указать формулы прямо в конфигурации.
|
||||||
|
|
||||||
|
Пример для калькулятора свечей:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// calculators/candles/calc.ts
|
||||||
|
|
||||||
|
export function calculateCandleStep(
|
||||||
|
stepId: string,
|
||||||
|
values: Record<string, number>
|
||||||
|
): number {
|
||||||
|
const { waxWeight = 0, waxPrice = 0, wickCount = 0, wickPrice = 0 } = values;
|
||||||
|
|
||||||
|
switch (stepId) {
|
||||||
|
case 'wax':
|
||||||
|
return (waxWeight / 1000) * waxPrice;
|
||||||
|
|
||||||
|
case 'wick':
|
||||||
|
return wickCount * wickPrice;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round(val: number): number {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 3: Создание файла config.ts
|
||||||
|
|
||||||
|
Это основной файл конфигурации калькулятора:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// calculators/candles/config.ts
|
||||||
|
|
||||||
|
import { CalculatorConfig } from '@/lib/calculator-types';
|
||||||
|
import { calculateCandleStep, round } from './calc';
|
||||||
|
|
||||||
|
export const candlesCalculatorConfig: CalculatorConfig = {
|
||||||
|
id: 'candles',
|
||||||
|
name: 'Калькулятор свечей',
|
||||||
|
description: 'Расчет себестоимости свечей',
|
||||||
|
icon: '🕯️',
|
||||||
|
|
||||||
|
// Поля ввода
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'candleName',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Название свечи',
|
||||||
|
placeholder: 'Введите название',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waxWeight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Вес воска, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waxPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена воска за 1 кг, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wickCount',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Количество фитилей',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wickPrice',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена одного фитиля, руб',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Шаги расчета
|
||||||
|
calculationSteps: [
|
||||||
|
{
|
||||||
|
id: 'wax',
|
||||||
|
name: 'Себестоимость воска',
|
||||||
|
formula: (values) => round(calculateCandleStep('wax', values)),
|
||||||
|
formulaDescription: '(вес_воска / 1000) * цена_воска',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wick',
|
||||||
|
name: 'Себестоимость фитилей',
|
||||||
|
formula: (values) => round(calculateCandleStep('wick', values)),
|
||||||
|
formulaDescription: 'количество_фитилей * цена_фитиля',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Подитоги
|
||||||
|
subtotals: [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
name: 'Итого себестоимость',
|
||||||
|
formula: (values, steps) => {
|
||||||
|
return round((steps.wax || 0) + (steps.wick || 0));
|
||||||
|
},
|
||||||
|
highlight: true,
|
||||||
|
formulaDescription: 'воск + фитили',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Дополнительные расчеты (опционально)
|
||||||
|
additionalCalculations: [
|
||||||
|
{
|
||||||
|
id: 'pricePerCandle',
|
||||||
|
name: 'Цена за свечу',
|
||||||
|
formula: (values, steps, subtotals) => {
|
||||||
|
const total = subtotals.total || 0;
|
||||||
|
return round(total);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Форматирование сообщения для Telegram (опционально)
|
||||||
|
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||||
|
const candleName = values.candleName || 'Без названия';
|
||||||
|
const totalCost = subtotals.total || 0;
|
||||||
|
|
||||||
|
let text = `🕯️ <b>Расчёт свечи:</b> <i>${candleName}</i>\n\n`;
|
||||||
|
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||||
|
text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
|
||||||
|
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 4: Регистрация калькулятора
|
||||||
|
|
||||||
|
Откройте файл `frontend/lib/calculator-registry.ts` и добавьте регистрацию:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { candlesCalculatorConfig } from '@/calculators/candles/config';
|
||||||
|
|
||||||
|
// В функции initializeCalculators():
|
||||||
|
registerCalculator(candlesCalculatorConfig);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Типы полей
|
||||||
|
|
||||||
|
### text
|
||||||
|
Текстовое поле ввода:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'productName',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Название продукта',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 1, // 1 или 2 колонки
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### number
|
||||||
|
Числовое поле ввода:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'weight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Вес, г',
|
||||||
|
defaultValue: '',
|
||||||
|
gridCols: 2,
|
||||||
|
validation: {
|
||||||
|
min: 0,
|
||||||
|
max: 10000,
|
||||||
|
},
|
||||||
|
// Группировка полей (опционально)
|
||||||
|
groupName: 'base', // Имя группы для группировки связанных полей
|
||||||
|
showStepAfter: 'base', // ID шага расчета, который показать после этой группы
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### file
|
||||||
|
Поле для загрузки файла (обычно фото):
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'photo',
|
||||||
|
type: 'file',
|
||||||
|
label: 'Фото продукта',
|
||||||
|
accept: 'image/*',
|
||||||
|
gridCols: 1,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Группировка полей
|
||||||
|
|
||||||
|
Для правильного расположения блоков расчета сразу после соответствующих полей используйте группировку:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
fields: [
|
||||||
|
// Группа "base" - после этих полей покажется расчет "base"
|
||||||
|
{
|
||||||
|
id: 'weight',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Вес, г',
|
||||||
|
groupName: 'base',
|
||||||
|
showStepAfter: 'base', // Первое поле группы должно иметь showStepAfter
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'price',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Цена, руб',
|
||||||
|
groupName: 'base', // Та же группа
|
||||||
|
},
|
||||||
|
// Блок расчета "base" автоматически появится здесь
|
||||||
|
|
||||||
|
// Группа "packaging"
|
||||||
|
{
|
||||||
|
id: 'box',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Коробка, руб',
|
||||||
|
groupName: 'packaging',
|
||||||
|
showStepAfter: 'packaging',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ribbon',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Лента, руб',
|
||||||
|
groupName: 'packaging',
|
||||||
|
},
|
||||||
|
// Блок расчета "packaging" автоматически появится здесь
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Формулы расчета
|
||||||
|
|
||||||
|
Формулы — это функции, которые принимают значения полей и возвращают число:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'baseCost',
|
||||||
|
name: 'Базовая стоимость',
|
||||||
|
formula: (values) => {
|
||||||
|
const weight = values.weight || 0;
|
||||||
|
const price = values.price || 0;
|
||||||
|
return weight * price;
|
||||||
|
},
|
||||||
|
formulaDescription: 'вес * цена', // Опционально, для документации
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
В формуле доступны все значения полей через объект `values`, где ключ — это `id` поля.
|
||||||
|
|
||||||
|
## Подитоги
|
||||||
|
|
||||||
|
Подитоги могут зависеть от шагов расчета:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
name: 'Итого',
|
||||||
|
formula: (values, steps) => {
|
||||||
|
// values — значения полей
|
||||||
|
// steps — результаты всех шагов расчета
|
||||||
|
return (steps.base || 0) + (steps.packaging || 0);
|
||||||
|
},
|
||||||
|
highlight: true, // Выделить итоговое значение
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительные расчеты
|
||||||
|
|
||||||
|
Используются для расчетов, которые зависят от подитогов или других дополнительных расчетов:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'finalPrice',
|
||||||
|
name: 'Итоговая цена',
|
||||||
|
formula: (values, steps, subtotals, additional) => {
|
||||||
|
const total = subtotals.total || 0;
|
||||||
|
const markup = values.markup || 0;
|
||||||
|
return total * (1 + markup / 100);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно**: Дополнительные расчеты выполняются последовательно. Если один расчет зависит от другого, расположите зависимый расчет **после** того, от которого он зависит:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
additionalCalculations: [
|
||||||
|
{
|
||||||
|
id: 'finalPrice',
|
||||||
|
name: 'Итоговая цена с наценкой',
|
||||||
|
formula: (values, steps, subtotals) => {
|
||||||
|
const total = subtotals.total || 0;
|
||||||
|
const markup = values.markup || 0;
|
||||||
|
return total * (1 + markup / 100);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pricePer100g',
|
||||||
|
name: 'Цена за 100 г',
|
||||||
|
formula: (values, steps, subtotals, additional) => {
|
||||||
|
// Используем уже рассчитанный finalPrice из additional
|
||||||
|
const finalPrice = additional?.finalPrice || 0;
|
||||||
|
const weight = values.weight || 0;
|
||||||
|
if (weight > 0) {
|
||||||
|
return (finalPrice / weight) * 100;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Форматирование сообщения для Telegram
|
||||||
|
|
||||||
|
Если не указать `formatTelegramMessage`, будет использовано универсальное форматирование. Но лучше создать свою функцию:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||||
|
// values — все поля формы (строки или числа)
|
||||||
|
// steps — результаты шагов расчета
|
||||||
|
// subtotals — результаты подитогов
|
||||||
|
// additional — результаты дополнительных расчетов
|
||||||
|
|
||||||
|
let text = `📊 <b>Расчёт:</b>\n\n`;
|
||||||
|
text += `Итого: ${subtotals.total.toFixed(1)} ₽\n`;
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка работы
|
||||||
|
|
||||||
|
1. Убедитесь, что калькулятор появился в меню на главной странице
|
||||||
|
2. Заполните форму и проверьте расчеты
|
||||||
|
3. Отправьте результат в Telegram и убедитесь, что сообщение корректно форматировано
|
||||||
|
|
||||||
|
## Советы
|
||||||
|
|
||||||
|
- Используйте функцию `round()` для округления результатов (обычно до 1 знака после запятой)
|
||||||
|
- Для сложных расчетов выносите логику в `calc.ts`
|
||||||
|
- Группируйте связанные поля визуально, используя `gridCols`
|
||||||
|
- Всегда указывайте `formulaDescription` для документации
|
||||||
|
- Тестируйте с нулевыми и пустыми значениями
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
Полные примеры калькуляторов можно найти в:
|
||||||
|
|
||||||
|
**Калькулятор мыла:**
|
||||||
|
- `frontend/calculators/soap/config.ts`
|
||||||
|
- `frontend/calculators/soap/calc.ts`
|
||||||
|
|
||||||
|
**Калькулятор свечей (пример простого калькулятора):**
|
||||||
|
- `frontend/calculators/candles/config.ts`
|
||||||
|
- `frontend/calculators/candles/calc.ts`
|
||||||
|
|
||||||
|
Оба примера демонстрируют использование группировки полей, шагов расчета, подитогов и дополнительных расчетов.
|
||||||
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
// lib/calc.ts
|
|
||||||
|
|
||||||
type Input = {
|
|
||||||
weight: number;
|
|
||||||
basePrice: number;
|
|
||||||
aromaPrice: number;
|
|
||||||
aromaWeight: number;
|
|
||||||
pigmentPrice: number;
|
|
||||||
pigmentWeight: number;
|
|
||||||
moldPrice: number;
|
|
||||||
packaging: {
|
|
||||||
box: number;
|
|
||||||
filler: number;
|
|
||||||
ribbon: number;
|
|
||||||
label: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function calculateTotal(data: Input) {
|
|
||||||
const {
|
|
||||||
weight,
|
|
||||||
basePrice,
|
|
||||||
aromaPrice,
|
|
||||||
aromaWeight,
|
|
||||||
pigmentPrice,
|
|
||||||
pigmentWeight,
|
|
||||||
moldPrice,
|
|
||||||
packaging,
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
const base = (weight / 1000) * basePrice;
|
|
||||||
const aroma = ((weight * 0.01) / aromaWeight) * aromaPrice;
|
|
||||||
const pigment = ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
|
||||||
const mold = moldPrice / 100;
|
|
||||||
const packagingCost = packaging.box + packaging.filler + packaging.ribbon + packaging.label;
|
|
||||||
const subtotal = base + aroma + pigment + mold + packagingCost;
|
|
||||||
const operational = subtotal * 0.05;
|
|
||||||
const total = subtotal + operational;
|
|
||||||
|
|
||||||
return {
|
|
||||||
base: round(base),
|
|
||||||
aroma: round(aroma),
|
|
||||||
pigment: round(pigment),
|
|
||||||
mold: round(mold),
|
|
||||||
packaging: round(packagingCost),
|
|
||||||
operational: round(operational),
|
|
||||||
total: round(total),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function round(val: number) {
|
|
||||||
return Math.round(val * 10) / 10;
|
|
||||||
}
|
|
||||||
46
frontend/lib/calculator-registry.ts
Normal file
46
frontend/lib/calculator-registry.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Система регистрации и управления калькуляторами
|
||||||
|
import { CalculatorConfig } from './calculator-types';
|
||||||
|
import { soapCalculatorConfig } from '@/calculators/soap/config';
|
||||||
|
import { candlesCalculatorConfig } from '@/calculators/candles/config';
|
||||||
|
|
||||||
|
// Реестр всех доступных калькуляторов
|
||||||
|
const calculators: Map<string, CalculatorConfig> = new Map();
|
||||||
|
|
||||||
|
// Регистрация калькуляторов
|
||||||
|
export function registerCalculator(config: CalculatorConfig): void {
|
||||||
|
if (calculators.has(config.id)) {
|
||||||
|
console.warn(`Калькулятор с ID "${config.id}" уже зарегистрирован. Перезаписываем.`);
|
||||||
|
}
|
||||||
|
calculators.set(config.id, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить калькулятор по ID
|
||||||
|
export function getCalculator(id: string): CalculatorConfig | undefined {
|
||||||
|
return calculators.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить список всех калькуляторов
|
||||||
|
export function getAllCalculators(): CalculatorConfig[] {
|
||||||
|
return Array.from(calculators.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить список ID всех калькуляторов
|
||||||
|
export function getAllCalculatorIds(): string[] {
|
||||||
|
return Array.from(calculators.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация: регистрация всех калькуляторов
|
||||||
|
export function initializeCalculators(): void {
|
||||||
|
// Регистрируем калькулятор мыла
|
||||||
|
registerCalculator(soapCalculatorConfig);
|
||||||
|
|
||||||
|
// Регистрируем калькулятор свечей
|
||||||
|
registerCalculator(candlesCalculatorConfig);
|
||||||
|
|
||||||
|
// Здесь будут регистрироваться другие калькуляторы
|
||||||
|
// registerCalculator(otherCalculatorConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическая инициализация при импорте модуля
|
||||||
|
initializeCalculators();
|
||||||
|
|
||||||
91
frontend/lib/calculator-types.ts
Normal file
91
frontend/lib/calculator-types.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Система типов для модульной архитектуры калькуляторов
|
||||||
|
|
||||||
|
export type FieldType = 'text' | 'number' | 'file';
|
||||||
|
|
||||||
|
export interface FieldConfig {
|
||||||
|
id: string;
|
||||||
|
type: FieldType;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
gridCols?: 1 | 2; // Колонки в grid (1 или 2)
|
||||||
|
required?: boolean;
|
||||||
|
validation?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
pattern?: string;
|
||||||
|
};
|
||||||
|
// Для полей типа file
|
||||||
|
accept?: string;
|
||||||
|
// ID шага расчета, который нужно показать после этой группы полей
|
||||||
|
showStepAfter?: string;
|
||||||
|
// Группа полей (поля с одинаковым groupName будут сгруппированы вместе)
|
||||||
|
groupName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculationStep {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// Формула может быть функцией или строкой для динамического вычисления
|
||||||
|
formula: (values: Record<string, number>) => number;
|
||||||
|
// Опционально: выражение в виде строки для документации
|
||||||
|
formulaDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtotalConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// Может быть ссылкой на шаг расчета или собственной формулой
|
||||||
|
formula: (values: Record<string, number>, steps: Record<string, number>) => number;
|
||||||
|
highlight?: boolean; // Для выделения итоговых значений
|
||||||
|
formulaDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculatorConfig {
|
||||||
|
id: string; // Уникальный идентификатор (например, 'soap', 'candles')
|
||||||
|
name: string; // Отображаемое название
|
||||||
|
description?: string; // Опциональное описание
|
||||||
|
icon?: string; // Эмодзи или иконка для меню
|
||||||
|
|
||||||
|
// Группировка полей (опционально)
|
||||||
|
fieldGroups?: {
|
||||||
|
name: string;
|
||||||
|
fields: string[]; // ID полей в этой группе
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// Конфигурация полей ввода
|
||||||
|
fields: FieldConfig[];
|
||||||
|
|
||||||
|
// Шаги расчета
|
||||||
|
calculationSteps: CalculationStep[];
|
||||||
|
|
||||||
|
// Подитоги
|
||||||
|
subtotals: SubtotalConfig[];
|
||||||
|
|
||||||
|
// Дополнительные расчеты (например, цена за 100г, с наценкой и т.д.)
|
||||||
|
additionalCalculations?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
formula: (values: Record<string, number>, steps: Record<string, number>, subtotals: Record<string, number>, additional?: Record<string, number>) => number;
|
||||||
|
formulaDescription?: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// Функция форматирования результата для Telegram
|
||||||
|
formatTelegramMessage?: (
|
||||||
|
values: Record<string, string | number>,
|
||||||
|
steps: Record<string, number>,
|
||||||
|
subtotals: Record<string, number>,
|
||||||
|
additional: Record<string, number>
|
||||||
|
) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тип для значений формы
|
||||||
|
export type CalculatorValues = Record<string, string>;
|
||||||
|
|
||||||
|
// Тип для результатов расчета
|
||||||
|
export interface CalculationResults {
|
||||||
|
steps: Record<string, number>;
|
||||||
|
subtotals: Record<string, number>;
|
||||||
|
additional?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user