diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b27ee9f --- /dev/null +++ b/CHANGELOG.md @@ -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/`) с демонстрацией всех возможностей системы + diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e696a35 --- /dev/null +++ b/PLAN.md @@ -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` + diff --git a/backend/bot.js b/backend/bot.js index f2ab48e..a037fa3 100644 --- a/backend/bot.js +++ b/backend/bot.js @@ -49,68 +49,76 @@ app.post( // Текстовые поля придут в req.body, файл — в req.file const { chat_id, - soapName, - weight, - basePrice, - aromaPrice, - aromaWeight, - pigmentPrice, - pigmentWeight, - moldPrice, - box, - filler, - ribbon, - labelValue, - markup, - totalCost, - finalPrice, - pricePer100g, + calculator_id, + calculator_name, + telegram_message, } = req.body; // Проверяем обязательные поля if (!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 = `📊 Расчёт: ${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Шаги расчёта:\n'; + Object.keys(steps).forEach(key => { + text += ` ${key}: ${Number(steps[key]).toFixed(2)} ₽\n`; + }); + } + + if (Object.keys(subtotals).length > 0) { + text += '\nПодитоги:\n'; + Object.keys(subtotals).forEach(key => { + text += ` ${key}: ${Number(subtotals[key]).toFixed(2)} ₽\n`; + }); + } + + if (Object.keys(additional).length > 0) { + text += '\nДополнительно:\n'; + Object.keys(additional).forEach(key => { + text += ` ${key}: ${Number(additional[key]).toFixed(2)} ₽\n`; + }); + } } - // Соберём сообщение так, чтобы в чат пришло всё, что ввели: - // 1. Название мыла - // 2. Вес и цена основы - // 3. Отдушка - // 4. Пигмент - // 5. Форма - // 6. Упаковка - // 7. Наценка - // 8. Итоги - - - - let text = `🧼 Расчёт мыла: ${soapName}\n\n`; - - text += `⚖️ Вес мыла: ${weight} г\n\n`; - // text += `🔹 Цена за 1 кг основы: ${basePrice} ₽/кг\n\n`; - - // text += `🔹 Отдушка: ${aromaWeight} г по ${aromaPrice} ₽/фасовка\n`; - // text += `🔹 Пигмент: ${pigmentWeight} г по ${pigmentPrice} ₽/фасовка\n\n`; - - // text += `🔹 Цена формы: ${moldPrice} ₽\n\n`; - - text += `📦 Упаковка:\n`; - text += ` 📥 Пакет/коробка: ${box} ₽\n`; - text += ` 🌾 Наполнитель: ${filler} ₽\n`; - text += ` 🎀 Лента: ${ribbon} ₽\n`; - text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`; - - text += `💹 Наценка: ${markup}%\n\n`; - - text += `📊 Итоги расчёта:\n`; - text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`; - text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`; - text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`; - - // Если пользователь прикрепил фото (req.file), шлём sendPhoto if (req.file) { const bufferStream = streamifier.createReadStream(req.file.buffer); @@ -146,6 +154,7 @@ app.post( // 6) Команда /menu — отправляем inline-кнопку с chat_id bot.setMyCommands([ { command: 'menu', description: 'Открыть калькулятор' }, + { command: 'myid', description: 'Узнать мой chat_id' }, ]); 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: ${chatId}\n\nВы можете открыть калькулятор напрямую по ссылке:\n${WEBAPP_BASE_URL}/?chat_id=${chatId}`, + { parse_mode: 'HTML' } + ); + } catch (err) { + console.error('Ошибка в обработчике /myid:', err); + } +}); + // 7) Ловим ошибки polling-а и логируем детали bot.on('polling_error', (err) => { console.error('Polling error:', err); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index a61d727..ba730f6 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,7 +1,9 @@ -import SoapCalculator from "@/components/SoapCalculator"; +import CalculatorMenu from "@/components/CalculatorMenu"; export default function Home() { return ( - +
+ +
); } diff --git a/frontend/calculators/candles/calc.ts b/frontend/calculators/candles/calc.ts new file mode 100644 index 0000000..059c32e --- /dev/null +++ b/frontend/calculators/candles/calc.ts @@ -0,0 +1,73 @@ +// Функции расчета для калькулятора свечей + +export function calculateCandleStep( + stepId: string, + values: Record +): 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, + steps: Record +): 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; +} + diff --git a/frontend/calculators/candles/config.ts b/frontend/calculators/candles/config.ts new file mode 100644 index 0000000..0213b3d --- /dev/null +++ b/frontend/calculators/candles/config.ts @@ -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 = `🕯️ Расчёт свечи: ${candleName}\n\n`; + text += `⚖️ Вес воска: ${waxWeight} г\n`; + text += `🕯️ Количество фитилей: ${wickCount} шт\n`; + text += `📦 Упаковка: ${packaging} ₽\n\n`; + text += `💹 Наценка: ${markup}%\n\n`; + text += `📊 Итоги расчёта:\n`; + text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`; + text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`; + text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`; + + return text; + }, +}; + diff --git a/frontend/calculators/soap/calc.ts b/frontend/calculators/soap/calc.ts new file mode 100644 index 0000000..6268911 --- /dev/null +++ b/frontend/calculators/soap/calc.ts @@ -0,0 +1,79 @@ +// Функции расчета для калькулятора мыла + +export function calculateSoapStep( + stepId: string, + values: Record +): 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, + steps: Record +): 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; +} + diff --git a/frontend/calculators/soap/config.ts b/frontend/calculators/soap/config.ts new file mode 100644 index 0000000..3ca0d26 --- /dev/null +++ b/frontend/calculators/soap/config.ts @@ -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 = `🧼 Расчёт мыла: ${soapName}\n\n`; + text += `⚖️ Вес мыла: ${weight} г\n\n`; + text += `📦 Упаковка:\n`; + text += ` 📥 Пакет/коробка: ${box} ₽\n`; + text += ` 🌾 Наполнитель: ${filler} ₽\n`; + text += ` 🎀 Лента: ${ribbon} ₽\n`; + text += ` 🏷️ Наклейка: ${label} ₽\n\n`; + text += `💹 Наценка: ${markup}%\n\n`; + text += `📊 Итоги расчёта:\n`; + text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`; + text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`; + text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`; + + return text; + }, +}; + diff --git a/frontend/components/CalculatorEngine.tsx b/frontend/components/CalculatorEngine.tsx new file mode 100644 index 0000000..cba0279 --- /dev/null +++ b/frontend/components/CalculatorEngine.tsx @@ -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 ( +
+
+ 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 + `} + /> + +
+
+ ); +}; + +const InputText = ({ + id, + label, + value, + onChange, +}: { + id: string; + label: string; + value: string; + onChange: (v: string) => void; +}) => { + return ( +
+ 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 + `} + /> + +
+ ); +}; + +const CostBlock = ({ + title, + value, + highlight = false, +}: { + title: string; + value: number; + highlight?: boolean; +}) => { + const displayValue = isNaN(value) || !isFinite(value) ? 0 : value; + return ( +
+ {title}: {displayValue.toFixed(1)} руб +
+ ); +}; + +export default function CalculatorEngine({ + calculatorId, + chatId: externalChatId, + onBack, +}: CalculatorEngineProps) { + const [config, setConfig] = useState(null); + const [values, setValues] = useState({}); + const [photoFile, setPhotoFile] = useState(null); + const [chatId, setChatId] = useState(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 ( +
+

Калькулятор не найден

+ {onBack && ( + + )} +
+ ); + } + + // Вычисляем все значения как числа + const numValues: Record = {}; + 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) => { + 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 = {}; + 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 = {}; + 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(); + + 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 ( +
+ {/* Кнопка назад */} + {onBack && ( + + )} + + {/* Логотип */} +
+ Logo +
+ + {chatId === null && ( +
+ ❗ Не найден chat_id. Откройте калькулятор через Telegram-бота. +
+ )} + + {/* Текстовые поля */} + {textFields.map((field) => ( + handleFieldChange(field.id, value)} + /> + ))} + + {/* Поле для фото */} + {photoField && ( + <> + + {photoFile && ( + Предпросмотр + )} + + )} + + {/* Группированные поля с расчетами между ними */} + {groupOrder.map((groupName) => { + const groupFields = fieldsByGroup[groupName] || []; + const firstField = groupFields[0]; + const showStepId = firstField?.showStepAfter; + + return ( +
+ {/* Поля группы */} +
+ {groupFields.map((field) => ( + handleFieldChange(field.id, value)} + gridCols={field.gridCols} + /> + ))} +
+ + {/* Блок расчета после группы */} + {showStepId && ( + s.id === showStepId)?.name || ''} + value={results.steps[showStepId] || 0} + /> + )} +
+ ); + })} + + {/* Несгруппированные поля */} + {ungroupedFields.length > 0 && ( +
+
+ {ungroupedFields.map((field) => ( + handleFieldChange(field.id, value)} + gridCols={field.gridCols} + /> + ))} +
+
+ )} + + {/* Подитоги */} + {config.subtotals.map((subtotal) => ( + + ))} + + {/* Дополнительные расчеты */} + {config.additionalCalculations?.map((calc) => ( + + ))} + + {/* Кнопка отправки */} + + + ); +} + diff --git a/frontend/components/CalculatorMenu.tsx b/frontend/components/CalculatorMenu.tsx new file mode 100644 index 0000000..156e201 --- /dev/null +++ b/frontend/components/CalculatorMenu.tsx @@ -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(null); + const [chatId, setChatId] = useState(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 ( + setSelectedCalculator(null)} + /> + ); + } + + // Иначе показываем меню выбора + return ( +
+ {/* Логотип */} +
+ Logo +
+ + {chatId === null && ( +
+ ❗ Не найден chat_id. Откройте калькулятор через Telegram-бота. +
+ )} + + {/* Заголовок */} +

+ Выберите калькулятор +

+ + {/* Список калькуляторов */} + {calculators.length === 0 ? ( +
+ Нет доступных калькуляторов +
+ ) : ( +
+ {calculators.map((calc) => ( + + ))} +
+ )} + + {/* Подсказка */} +
+ Выберите калькулятор для начала расчёта +
+
+ ); +} + diff --git a/frontend/components/SoapCalculator.tsx b/frontend/components/SoapCalculator.tsx deleted file mode 100644 index b09c24c..0000000 --- a/frontend/components/SoapCalculator.tsx +++ /dev/null @@ -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 ( -
- 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 - `} - /> - -
- ); -}; - -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(null); - const [chatId, setChatId] = useState(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) => { - 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 ( -
- {/* Центрированный адаптивный логотип */} -
- Logo -
- - {chatId === null && ( -
- ❗ Не найден chat_id. Откройте калькулятор через Telegram-бота. -
- )} - - {/* Название мыла */} -
- 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 - `} - /> - -
- - {/* Фото мыла */} - - {photoFile && ( - Предпросмотр мыла - )} - - {/* Блок «Основа» */} -
- - -
- - - {/* Блок «Отдушка» */} -
- - -
- - - {/* Блок «Пигмент» */} -
- - -
- - - {/* Блок «Форма» */} - - - - {/* Блок «Упаковка» */} -
- - - - -
- - - - - {/* Блок «Наценка и цена 100 г» */} -
- -
- - - - {/* Кнопка «Отправить расчёт» */} - - - ); -} - -type CostBlockProps = { - title: string; - value: number; - highlight?: boolean; -}; - -const CostBlock = ({ title, value, highlight = false }: CostBlockProps) => ( -
- {title}: {value.toFixed(1)} руб -
-); diff --git a/frontend/docs/calculator-creation-guide.md b/frontend/docs/calculator-creation-guide.md new file mode 100644 index 0000000..ea39520 --- /dev/null +++ b/frontend/docs/calculator-creation-guide.md @@ -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 +): 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 = `🕯️ Расчёт свечи: ${candleName}\n\n`; + text += `📊 Итоги расчёта:\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 = `📊 Расчёт:\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` + +Оба примера демонстрируют использование группировки полей, шагов расчета, подитогов и дополнительных расчетов. + diff --git a/frontend/lib/calc.ts b/frontend/lib/calc.ts deleted file mode 100644 index 55a1029..0000000 --- a/frontend/lib/calc.ts +++ /dev/null @@ -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; -} diff --git a/frontend/lib/calculator-registry.ts b/frontend/lib/calculator-registry.ts new file mode 100644 index 0000000..f58a2ed --- /dev/null +++ b/frontend/lib/calculator-registry.ts @@ -0,0 +1,46 @@ +// Система регистрации и управления калькуляторами +import { CalculatorConfig } from './calculator-types'; +import { soapCalculatorConfig } from '@/calculators/soap/config'; +import { candlesCalculatorConfig } from '@/calculators/candles/config'; + +// Реестр всех доступных калькуляторов +const calculators: Map = 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(); + diff --git a/frontend/lib/calculator-types.ts b/frontend/lib/calculator-types.ts new file mode 100644 index 0000000..461070a --- /dev/null +++ b/frontend/lib/calculator-types.ts @@ -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) => number; + // Опционально: выражение в виде строки для документации + formulaDescription?: string; +} + +export interface SubtotalConfig { + id: string; + name: string; + // Может быть ссылкой на шаг расчета или собственной формулой + formula: (values: Record, steps: Record) => 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, steps: Record, subtotals: Record, additional?: Record) => number; + formulaDescription?: string; + }[]; + + // Функция форматирования результата для Telegram + formatTelegramMessage?: ( + values: Record, + steps: Record, + subtotals: Record, + additional: Record + ) => string; +} + +// Тип для значений формы +export type CalculatorValues = Record; + +// Тип для результатов расчета +export interface CalculationResults { + steps: Record; + subtotals: Record; + additional?: Record; +} +