Compare commits

...

6 Commits

Author SHA1 Message Date
9fd81a9758 Merge dev into main: Modular calculator architecture with documentation 2025-11-02 16:16:47 +03:00
6a81dd6b96 docs: Fix README links to docs folder 2025-11-02 16:16:40 +03:00
234f1f5efe Merge branch 'main' of http://192.168.0.19:3002/DosAi/DoSoapCalc into dev 2025-11-02 16:16:29 +03:00
88ed4b3580 docs: Add knowledge base, project rules and organize documentation
- Created .cursorrules for development context

- Added KNOWLEDGE_BASE.md with architecture details

- Added PROJECT_RULES.md with coding standards

- Updated README.md with project overview

- Moved CHANGELOG.md and PLAN.md to docs/ folder

- Updated CHANGELOG.md with latest changes
2025-11-02 16:09:30 +03:00
8d5ecd401d Fix production build errors 2025-11-02 15:55:21 +03:00
02c7520c90 Refactor: Modular calculator architecture
Created modular system for calculators, added soap and candles calculators, universal components, updated backend
2025-11-02 15:45:07 +03:00
20 changed files with 2594 additions and 508 deletions

83
.cursorrules Normal file
View File

@ -0,0 +1,83 @@
# Правила разработки проекта DoSoap
## Общая информация о проекте
DoSoap - это модульная система калькуляторов себестоимости для ручной работы (мыло, свечи и др.). Проект состоит из фронтенда (Next.js) и бэкенда (Express + Telegram Bot API).
### Архитектура
Проект использует модульную архитектуру, где каждый калькулятор - это отдельный модуль в папке `frontend/calculators/[название]/`. Каждый модуль содержит:
- `config.ts` - конфигурация калькулятора (поля, формулы, подитоги)
- `calc.ts` - опциональные функции расчета для сложной логики
### Ключевые компоненты
- `CalculatorEngine.tsx` - универсальный компонент для рендеринга любого калькулятора
- `CalculatorMenu.tsx` - меню выбора калькулятора
- `calculator-registry.ts` - реестр всех калькуляторов
- `calculator-types.ts` - типы TypeScript для системы
### Технологический стек
**Frontend:**
- Next.js 15.3.3 (App Router)
- React 19
- TypeScript 5
- Tailwind CSS 4
- ESLint
**Backend:**
- Express.js 5
- node-telegram-bot-api
- multer (для загрузки файлов)
### Важные правила кодирования
1. **Всегда используй TypeScript типы** - не используй `any`, используй строгую типизацию
2. **Модульность** - новый калькулятор = новая папка в `calculators/` с минимум 2 файлами (config.ts и опционально calc.ts)
3. **Регистрация** - каждый новый калькулятор должен быть зарегистрирован в `calculator-registry.ts`
4. **Группировка полей** - используй `groupName` и `showStepAfter` для правильного расположения блоков расчета
5. **Обработка ошибок** - все формулы должны проверять деление на ноль и пустые значения, возвращать 0 вместо NaN
6. **ESLint** - код должен проходить линтер без ошибок (особенно важно для production build)
### Структура калькулятора
```typescript
// config.ts
export const myCalculatorConfig: CalculatorConfig = {
id: 'unique-id',
name: 'Название',
description: 'Описание',
icon: '🎯',
fields: [...], // Поля ввода
calculationSteps: [...], // Шаги расчета
subtotals: [...], // Подитоги
additionalCalculations: [...], // Дополнительные расчеты
formatTelegramMessage: (...) => string // Форматирование для Telegram
};
```
### API Endpoints
**Backend:**
- `POST /api/submit` - отправка расчета в Telegram
- Команда `/myid` - получение chat_id пользователя
**Frontend:**
- API URL определяется автоматически: localhost для разработки, продакшн для деплоя
### Деплой
Проект деплоится на сервер через:
1. `git pull` на сервере
2. `npm run build` в папке frontend
3. `pm2 restart` для перезапуска процессов
### Файлы и пути
- Документация: `docs/` (корень проекта)
- Калькуляторы: `frontend/calculators/`
- Компоненты: `frontend/components/`
- Типы и утилиты: `frontend/lib/`
- Бэкенд: `backend/bot.js`

109
README.md Normal file
View File

@ -0,0 +1,109 @@
# DoSoap - Модульная система калькуляторов себестоимости
Веб-приложение для расчета себестоимости продукции ручной работы (мыло, свечи и др.) с интеграцией Telegram-бота.
## 🚀 Возможности
- **Модульная архитектура**: Легкое добавление новых калькуляторов
- **Универсальный движок**: Один компонент для всех калькуляторов
- **Telegram интеграция**: Автоматическая отправка расчетов в Telegram
- **Динамические формы**: Поля и расчеты определяются конфигурацией
- **Группировка полей**: Правильное расположение блоков расчета
## 📁 Структура проекта
```
DoSoap/
├── frontend/ # Next.js приложение
│ ├── app/ # App Router страницы
│ ├── calculators/ # Модули калькуляторов
│ │ ├── soap/ # Калькулятор мыла
│ │ └── candles/ # Калькулятор свечей
│ ├── components/ # React компоненты
│ ├── lib/ # Утилиты и типы
│ └── docs/ # Документация
├── backend/ # Express + Telegram Bot
└── docs/ # Документация проекта
```
## 🛠️ Технологический стек
**Frontend:**
- Next.js 15.3.3 (App Router)
- React 19
- TypeScript 5
- Tailwind CSS 4
**Backend:**
- Express.js 5
- node-telegram-bot-api
- multer (загрузка файлов)
## 📦 Установка и запуск
### Локальная разработка
```bash
# Frontend
cd frontend
npm install
npm run dev
# Открыть http://localhost:3000
# Backend
cd backend
npm install
node bot.js
# Сервер запустится на http://localhost:3001
```
### Production сборка
```bash
cd frontend
npm run build
# Статические файлы в frontend/out/
```
## 🎯 Добавление нового калькулятора
1. Создать папку `frontend/calculators/[название]/`
2. Создать `config.ts` с конфигурацией
3. Опционально создать `calc.ts` для сложных расчетов
4. Зарегистрировать в `frontend/lib/calculator-registry.ts`
Подробные инструкции: [`docs/calculator-creation-guide.md`](docs/calculator-creation-guide.md)
## 📚 Документация
- **[Руководство по созданию калькуляторов](frontend/docs/calculator-creation-guide.md)** - Подробная инструкция
- **[База знаний](docs/KNOWLEDGE_BASE.md)** - Архитектура и технические детали
- **[Правила проекта](docs/PROJECT_RULES.md)** - Стандарты кодирования
- **[История изменений](docs/CHANGELOG.md)** - Changelog проекта
- **[План работ](docs/PLAN.md)** - Отслеживание задач
## 🔧 Деплой
Проект деплоится на сервер через PM2:
```bash
# На сервере
cd ~/projects/DoSoapCalc
git pull origin dev
cd frontend && npm run build
pm2 restart dosoap-frontend dosoap-backend
```
## 🧪 Доступные калькуляторы
- **Калькулятор мыла** 🧼 - Расчет себестоимости мыла ручной работы
- **Калькулятор свечей** 🕯️ - Расчет себестоимости свечей
## 📝 Лицензия
ISC
## 👤 Автор
DosAi

View File

@ -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 = `📊 <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
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: <code>${chatId}</code>\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);

52
docs/CHANGELOG.md Normal file
View File

@ -0,0 +1,52 @@
# История изменений
## [Не опубликовано] - Рефакторинг под модульную архитектуру
### Добавлено
- **Модульная система калькуляторов**: Создана универсальная архитектура для добавления новых калькуляторов через конфигурационные файлы
- **Система типов** (`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/`) с демонстрацией всех возможностей системы
- **Исправления для production build**: Устранены ошибки ESLint (удален `any`, добавлен комментарий для `<img>`)
- **База знаний и правила проекта**: Создана документация для контекста разработки (`.cursorrules`, `docs/KNOWLEDGE_BASE.md`, `docs/PROJECT_RULES.md`)
- **Организация документации**: Вся документация собрана в папке `docs/`

247
docs/KNOWLEDGE_BASE.md Normal file
View File

@ -0,0 +1,247 @@
# База знаний проекта DoSoap
## Назначение проекта
DoSoap - это веб-приложение для расчета себестоимости продукции ручной работы. Пользователи могут:
1. Выбрать калькулятор (мыло, свечи и т.д.)
2. Ввести параметры продукта
3. Получить автоматический расчет себестоимости
4. Отправить результат в Telegram-бота
## Архитектура системы
### Frontend (Next.js)
**Структура:**
```
frontend/
app/ # Next.js App Router
page.tsx # Главная страница с меню
layout.tsx # Общий layout
calculators/ # Модули калькуляторов
soap/ # Калькулятор мыла
config.ts # Конфигурация
calc.ts # Функции расчета
candles/ # Калькулятор свечей
config.ts
calc.ts
components/ # React компоненты
CalculatorEngine.tsx # Универсальный движок
CalculatorMenu.tsx # Меню выбора
lib/ # Утилиты и типы
calculator-types.ts # TypeScript типы
calculator-registry.ts # Реестр калькуляторов
docs/ # Документация
calculator-creation-guide.md
```
**Технологии:**
- Next.js 15.3.3 с App Router
- React 19
- TypeScript 5
- Tailwind CSS 4
- Статический экспорт (`output: 'export'`)
### Backend (Express + Telegram)
**Файлы:**
- `backend/bot.js` - основной файл бэкенда
**Функционал:**
- Обработка POST запросов от фронтенда
- Интеграция с Telegram Bot API
- Загрузка и отправка фотографий
- Форматирование сообщений для Telegram
**API:**
- `POST /api/submit` - принимает данные расчета и отправляет в Telegram
- Команда `/myid` - возвращает chat_id пользователя
## Типы данных
### FieldConfig
Определяет поле ввода в калькуляторе:
```typescript
{
id: string; // Уникальный ID
type: 'text' | 'number' | 'file';
label: string; // Название поля
defaultValue?: string;
gridCols?: 1 | 2; // Ширина в grid
required?: boolean;
accept?: string; // Для файлов
groupName?: string; // Группа для группировки
showStepAfter?: string; // ID расчета после группы
}
```
### CalculationStep
Шаг расчета (например, "Себестоимость основы"):
```typescript
{
id: string;
name: string;
formula: (values: Record<string, number>) => number;
formulaDescription?: string;
}
```
### SubtotalConfig
Подитог (например, "Итого себестоимость"):
```typescript
{
id: string;
name: string;
formula: (values, steps) => number;
highlight?: boolean; // Выделить визуально
formulaDescription?: string;
}
```
### AdditionalCalculation
Дополнительный расчет (например, "Цена за 100г"):
```typescript
{
id: string;
name: string;
formula: (values, steps, subtotals, additional?) => number;
formulaDescription?: string;
}
```
## Поток работы калькулятора
1. **Выбор калькулятора**: Пользователь выбирает калькулятор в меню
2. **Загрузка конфигурации**: `CalculatorEngine` получает конфигурацию из реестра
3. **Рендеринг полей**: Динамически создаются поля на основе `fields` из конфигурации
4. **Ввод данных**: Пользователь заполняет поля
5. **Расчет**: При изменении полей автоматически пересчитываются все шаги, подитоги и дополнительные расчеты
6. **Отправка**: Данные отправляются на бэкенд через `/api/submit`
7. **Telegram**: Бэкенд отправляет форматированное сообщение в Telegram
## Группировка полей
Поля можно группировать для правильного расположения блоков расчета:
```typescript
{
id: 'weight',
groupName: 'base', // Группа "base"
showStepAfter: 'base', // После группы показать расчет "base"
}
{
id: 'price',
groupName: 'base', // Тот же группа
}
// Блок расчета "Себестоимость основы" появится здесь
```
## Форматирование Telegram сообщений
Каждый калькулятор может иметь функцию `formatTelegramMessage`:
```typescript
formatTelegramMessage: (values, steps, subtotals, additional) => {
let text = `🧼 <b>Расчёт мыла:</b>\n\n`;
text += `💵 Себестоимость: ${subtotals.total.toFixed(1)} ₽\n`;
return text;
}
```
Если функция не указана, используется универсальное форматирование.
## API интеграция
### Определение URL
Фронтенд автоматически определяет URL API:
```typescript
const isLocalhost = window.location.hostname === 'localhost';
const apiUrl = isLocalhost
? 'http://localhost:3001/api/submit'
: 'https://api-dosoap.duckdns.org/api/submit';
```
### Формат запроса
```typescript
FormData {
chat_id: string;
calculator_id: string;
calculator_name: string;
telegram_message: string; // Готовое сообщение
photo?: File; // Опциональное фото
// ... остальные поля
}
```
## Добавление нового калькулятора
### Шаг 1: Создать модуль
```bash
mkdir frontend/calculators/my-calc
touch frontend/calculators/my-calc/config.ts
touch frontend/calculators/my-calc/calc.ts # опционально
```
### Шаг 2: Создать конфигурацию
См. `frontend/docs/calculator-creation-guide.md`
### Шаг 3: Зарегистрировать
В `frontend/lib/calculator-registry.ts`:
```typescript
import { myCalcConfig } from '@/calculators/my-calc/config';
registerCalculator(myCalcConfig);
```
## Деплой
### Сервер
- IP: 192.168.0.19
- Пользователь: dosai
- Путь: ~/projects/DoSoapCalc
### Процессы PM2
- `dosoap-frontend` - Next.js приложение
- `dosoap-backend` - Express сервер
### Команды деплоя
```bash
# На сервере
cd ~/projects/DoSoapCalc
git pull origin dev
cd frontend && npm run build
pm2 restart dosoap-frontend dosoap-backend
```
## Обработка ошибок
### Расчеты
Все формулы должны проверять:
- Деление на ноль → вернуть 0
- Пустые значения → вернуть 0
- NaN → вернуть 0
Пример:
```typescript
if (weight <= 0 || price <= 0) return 0;
return (weight / 1000) * price;
```
### API запросы
```typescript
try {
const res = await fetch(apiUrl, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Server error');
} catch (err) {
alert('Ошибка сети при отправке расчёта');
}
```
## Известные особенности
1. **Статический экспорт**: Next.js собирается в статические файлы, нет серверного рендеринга
2. **Telegram Bot Token**: Хранится в переменных окружения на сервере
3. **Chat ID**: Передается через URL параметр `?chat_id=...` или через Telegram бота
4. **Фото**: Отправляются через FormData, обрабатываются multer на бэкенде

102
docs/PLAN.md Normal file
View 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`

93
docs/PROJECT_RULES.md Normal file
View File

@ -0,0 +1,93 @@
# Правила проекта DoSoap
## Общие принципы
### 1. Модульность
- Каждый калькулятор должен быть независимым модулем
- Не должно быть жестких зависимостей между калькуляторами
- Добавление нового калькулятора не должно ломать существующие
### 2. Типизация
- **Запрещено** использовать `any` в TypeScript
- Все типы должны быть определены в `calculator-types.ts`
- Все функции должны иметь явные типы параметров и возвращаемого значения
### 3. Обработка ошибок
- Все расчеты должны проверять деление на ноль
- Пустые значения должны возвращать 0, а не NaN
- API запросы должны обрабатывать ошибки сети
### 4. Форматирование кода
- Используй ESLint правила проекта
- Код должен проходить `npm run lint` без ошибок
- Production build не должен падать из-за линтера
## Структура модуля калькулятора
### Обязательные файлы:
1. `config.ts` - конфигурация калькулятора (обязательно)
2. `calc.ts` - функции расчета (опционально, только для сложной логики)
### Регистрация:
Каждый новый калькулятор должен быть зарегистрирован в `frontend/lib/calculator-registry.ts`:
```typescript
import { myCalculatorConfig } from '@/calculators/my-calc/config';
registerCalculator(myCalculatorConfig);
```
## Соглашения об именовании
### Файлы и папки:
- Калькуляторы: `kebab-case` (например: `soap`, `candles`)
- Компоненты: `PascalCase.tsx`
- Утилиты: `camelCase.ts`
### ID полей и расчетов:
- Используй `camelCase` для ID
- Названия должны быть понятными и описательными
## Работа с Git
### Ветки:
- `main` / `master` - продакшн
- `dev` - разработка
### Коммиты:
- Используй понятные сообщения коммитов
- Один коммит = одно логическое изменение
### Перед коммитом:
1. Проверь линтер: `npm run lint`
2. Убедись, что сборка проходит: `npm run build`
3. Проверь типы: TypeScript должен компилироваться без ошибок
## Деплой на сервер
### Порядок действий:
1. Закоммитить изменения в ветку `dev`
2. Отправить на сервер: `git push origin dev`
3. На сервере: `git pull origin dev`
4. Пересобрать фронтенд: `cd frontend && npm run build`
5. Перезапустить процессы: `pm2 restart dosoap-frontend dosoap-backend`
## Тестирование
### Локальная разработка:
- Frontend: `npm run dev` (порт 3000)
- Backend: `node bot.js` (порт 3001)
### Проверка перед деплоем:
- [ ] Все расчеты работают корректно
- [ ] Нет ошибок в консоли браузера
- [ ] Отправка в Telegram работает
- [ ] Production build успешно собирается
- [ ] Линтер не выдает ошибок
## Запрещенные практики
1. ❌ Использование `any` в TypeScript
2. ❌ Прямые импорты между калькуляторами
3. ❌ Изменение `CalculatorEngine` без обновления документации
4. ❌ Хардкод значений, которые должны быть в конфигурации
5. ❌ Коммит без проверки линтера и сборки

View File

@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Калькулятор DoSoap",
description: "Калькулятор себестоимости ручных изделий",
};
export default function RootLayout({

View File

@ -1,7 +1,9 @@
import SoapCalculator from "@/components/SoapCalculator";
import CalculatorMenu from "@/components/CalculatorMenu";
export default function Home() {
return (
<SoapCalculator />
<div className="min-h-screen py-8">
<CalculatorMenu />
</div>
);
}

View 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;
}

View 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;
},
};

View 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;
}

View 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;
},
};

View File

@ -0,0 +1,550 @@
'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 это не используется, только для consistency)
numValues[key] = 0; // Текстовые поля не участвуют в числовых расчетах
}
});
// Выполняем расчеты
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 && (
// eslint-disable-next-line @next/next/no-img-element
<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>
);
}

View 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>
);
}

View File

@ -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>
);

View 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`
Оба примера демонстрируют использование группировки полей, шагов расчета, подитогов и дополнительных расчетов.

View File

@ -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;
}

View 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();

View 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>;
}