Compare commits
6 Commits
800aaafb24
...
9fd81a9758
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fd81a9758 | |||
| 6a81dd6b96 | |||
| 234f1f5efe | |||
| 88ed4b3580 | |||
| 8d5ecd401d | |||
| 02c7520c90 |
83
.cursorrules
Normal file
83
.cursorrules
Normal 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
109
README.md
Normal 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
|
||||
|
||||
131
backend/bot.js
131
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 = `📊 <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
52
docs/CHANGELOG.md
Normal 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
247
docs/KNOWLEDGE_BASE.md
Normal 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
102
docs/PLAN.md
Normal file
@ -0,0 +1,102 @@
|
||||
# План работ: Модульная архитектура калькуляторов
|
||||
|
||||
## Статус выполнения
|
||||
|
||||
- [x] Создать систему типов (calculator-types.ts) с FieldConfig, CalculationStep, SubtotalConfig, CalculatorConfig
|
||||
- [x] Вынести калькулятор мыла в модуль (calculators/soap/config.ts и calc.ts)
|
||||
- [x] Создать универсальный компонент CalculatorEngine.tsx для динамического рендеринга и расчетов
|
||||
- [x] Создать компонент CalculatorMenu.tsx для выбора калькулятора на главной странице
|
||||
- [x] Создать систему регистрации калькуляторов (calculator-registry.ts)
|
||||
- [x] Обновить app/page.tsx для отображения меню выбора калькулятора
|
||||
- [x] Адаптировать backend/bot.js для универсальной обработки разных калькуляторов
|
||||
- [x] Удалить старые файлы SoapCalculator.tsx и calc.ts после миграции
|
||||
- [x] Создать документацию по созданию калькуляторов (calculator-creation-guide.md)
|
||||
- [x] Создать CHANGELOG.md с описанием изменений
|
||||
- [x] Создать PLAN.md с планом работ и отслеживанием прогресса
|
||||
|
||||
## Выполненные задачи
|
||||
|
||||
### ✅ Этап 1: Система типов
|
||||
- Создан файл `frontend/lib/calculator-types.ts`
|
||||
- Определены типы: `FieldConfig`, `CalculationStep`, `SubtotalConfig`, `CalculatorConfig`
|
||||
- Поддержка типов полей: text, number, file
|
||||
- Типы для результатов расчетов: `CalculatorValues`, `CalculationResults`
|
||||
|
||||
### ✅ Этап 2: Модуль калькулятора мыла
|
||||
- Создана папка `frontend/calculators/soap/`
|
||||
- Создан файл `calc.ts` с функциями расчета
|
||||
- Создан файл `config.ts` с полной конфигурацией калькулятора мыла
|
||||
- Все поля, формулы и подитоги перенесены в модуль
|
||||
|
||||
### ✅ Этап 3: Универсальные компоненты
|
||||
- Создан `CalculatorEngine.tsx` — динамический рендеринг на основе конфигурации
|
||||
- Создан `CalculatorMenu.tsx` — меню выбора калькулятора
|
||||
- Обновлен `app/page.tsx` для использования меню
|
||||
|
||||
### ✅ Этап 4: Система регистрации
|
||||
- Создан `calculator-registry.ts` для управления калькуляторами
|
||||
- Реализованы функции: `registerCalculator`, `getCalculator`, `getAllCalculators`
|
||||
- Автоматическая инициализация при импорте
|
||||
|
||||
### ✅ Этап 5: Адаптация бэкенда
|
||||
- Обновлен `backend/bot.js` для универсальной обработки
|
||||
- Поддержка готовых сообщений из конфигурации калькулятора
|
||||
- Fallback на универсальное форматирование при отсутствии `formatTelegramMessage`
|
||||
|
||||
### ✅ Этап 6: Очистка
|
||||
- Удален старый `SoapCalculator.tsx`
|
||||
- Удален старый `calc.ts`
|
||||
- Все зависимости обновлены
|
||||
|
||||
### ✅ Этап 7: Документация
|
||||
- Создано руководство `docs/calculator-creation-guide.md`
|
||||
- Создан `CHANGELOG.md`
|
||||
- Создан `PLAN.md` (этот файл)
|
||||
|
||||
## Следующие шаги (будущие улучшения)
|
||||
|
||||
### Возможные улучшения
|
||||
- [ ] Добавить валидацию полей на основе конфигурации
|
||||
- [ ] Добавить поддержку условных полей (показывать/скрывать в зависимости от значений)
|
||||
- [x] Добавить поддержку групп полей с показом расчетов между группами
|
||||
- [x] Создать пример калькулятора свечей для демонстрации
|
||||
- [ ] Добавить тесты для модульной системы
|
||||
- [ ] Улучшить обработку ошибок валидации
|
||||
- [ ] Добавить поддержку единиц измерения (г, кг, шт и т.д.)
|
||||
- [ ] Добавить сохранение истории расчетов (локально)
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
frontend/
|
||||
calculators/ # Модули калькуляторов
|
||||
soap/
|
||||
config.ts # Конфигурация калькулятора мыла
|
||||
calc.ts # Функции расчета
|
||||
candles/
|
||||
config.ts # Конфигурация калькулятора свечей
|
||||
calc.ts # Функции расчета
|
||||
components/
|
||||
CalculatorEngine.tsx # Универсальный компонент
|
||||
CalculatorMenu.tsx # Меню выбора
|
||||
lib/
|
||||
calculator-types.ts # Типы системы
|
||||
calculator-registry.ts # Реестр калькуляторов
|
||||
app/
|
||||
page.tsx # Главная страница с меню
|
||||
docs/
|
||||
calculator-creation-guide.md # Руководство
|
||||
|
||||
backend/
|
||||
bot.js # Универсальный обработчик API
|
||||
```
|
||||
|
||||
## Как добавить новый калькулятор
|
||||
|
||||
1. Создать папку `frontend/calculators/[название]/`
|
||||
2. Создать файл `config.ts` с конфигурацией
|
||||
3. Опционально создать `calc.ts` для сложных расчетов
|
||||
4. Зарегистрировать в `calculator-registry.ts`
|
||||
|
||||
Подробные инструкции в `frontend/docs/calculator-creation-guide.md`
|
||||
|
||||
93
docs/PROJECT_RULES.md
Normal file
93
docs/PROJECT_RULES.md
Normal 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. ❌ Коммит без проверки линтера и сборки
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
73
frontend/calculators/candles/calc.ts
Normal file
73
frontend/calculators/candles/calc.ts
Normal file
@ -0,0 +1,73 @@
|
||||
// Функции расчета для калькулятора свечей
|
||||
|
||||
export function calculateCandleStep(
|
||||
stepId: string,
|
||||
values: Record<string, number>
|
||||
): number {
|
||||
const {
|
||||
waxWeight = 0,
|
||||
waxPrice = 0,
|
||||
wickCount = 0,
|
||||
wickPrice = 0,
|
||||
fragrancePrice = 0,
|
||||
fragranceWeight = 0,
|
||||
dyePrice = 0,
|
||||
dyeWeight = 0,
|
||||
} = values;
|
||||
|
||||
switch (stepId) {
|
||||
case 'wax':
|
||||
if (waxWeight <= 0 || waxPrice <= 0) return 0;
|
||||
return (waxWeight / 1000) * waxPrice;
|
||||
|
||||
case 'wick':
|
||||
if (wickCount <= 0 || wickPrice <= 0) return 0;
|
||||
return wickCount * wickPrice;
|
||||
|
||||
case 'fragrance':
|
||||
if (waxWeight <= 0 || fragranceWeight <= 0 || fragrancePrice <= 0) return 0;
|
||||
// 10% отдушки от веса воска
|
||||
return ((waxWeight * 0.10) / fragranceWeight) * fragrancePrice;
|
||||
|
||||
case 'dye':
|
||||
if (waxWeight <= 0 || dyeWeight <= 0 || dyePrice <= 0) return 0;
|
||||
// 1% красителя от веса воска
|
||||
return ((waxWeight * 0.01) / dyeWeight) * dyePrice;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateCandleSubtotal(
|
||||
subtotalId: string,
|
||||
values: Record<string, number>,
|
||||
steps: Record<string, number>
|
||||
): number {
|
||||
switch (subtotalId) {
|
||||
case 'operational':
|
||||
const subtotal =
|
||||
(steps.wax || 0) +
|
||||
(steps.wick || 0) +
|
||||
(steps.fragrance || 0) +
|
||||
(steps.dye || 0);
|
||||
return subtotal * 0.05;
|
||||
|
||||
case 'total':
|
||||
return (
|
||||
(steps.wax || 0) +
|
||||
(steps.wick || 0) +
|
||||
(steps.fragrance || 0) +
|
||||
(steps.dye || 0) +
|
||||
(steps.operational || 0)
|
||||
);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function round(val: number): number {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
|
||||
246
frontend/calculators/candles/config.ts
Normal file
246
frontend/calculators/candles/config.ts
Normal file
@ -0,0 +1,246 @@
|
||||
// Конфигурация калькулятора свечей
|
||||
import { CalculatorConfig } from '@/lib/calculator-types';
|
||||
import { calculateCandleStep, calculateCandleSubtotal, round } from './calc';
|
||||
|
||||
export const candlesCalculatorConfig: CalculatorConfig = {
|
||||
id: 'candles',
|
||||
name: 'Калькулятор свечей',
|
||||
description: 'Расчет себестоимости свечей ручной работы',
|
||||
icon: '🕯️',
|
||||
|
||||
fields: [
|
||||
{
|
||||
id: 'candleName',
|
||||
type: 'text',
|
||||
label: 'Название свечи',
|
||||
placeholder: 'Введите название',
|
||||
defaultValue: '',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'photo',
|
||||
type: 'file',
|
||||
label: 'Фото свечи (необязательно)',
|
||||
accept: 'image/*',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'waxWeight',
|
||||
type: 'number',
|
||||
label: 'Вес воска, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'wax',
|
||||
showStepAfter: 'wax',
|
||||
},
|
||||
{
|
||||
id: 'waxPrice',
|
||||
type: 'number',
|
||||
label: 'Цена воска за 1 кг, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'wax',
|
||||
},
|
||||
{
|
||||
id: 'wickCount',
|
||||
type: 'number',
|
||||
label: 'Количество фитилей',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'wick',
|
||||
showStepAfter: 'wick',
|
||||
},
|
||||
{
|
||||
id: 'wickPrice',
|
||||
type: 'number',
|
||||
label: 'Цена одного фитиля, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'wick',
|
||||
},
|
||||
{
|
||||
id: 'fragrancePrice',
|
||||
type: 'number',
|
||||
label: 'Цена отдушки, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'fragrance',
|
||||
showStepAfter: 'fragrance',
|
||||
},
|
||||
{
|
||||
id: 'fragranceWeight',
|
||||
type: 'number',
|
||||
label: 'Фасовка отдушки, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'fragrance',
|
||||
},
|
||||
{
|
||||
id: 'dyePrice',
|
||||
type: 'number',
|
||||
label: 'Цена красителя, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'dye',
|
||||
showStepAfter: 'dye',
|
||||
},
|
||||
{
|
||||
id: 'dyeWeight',
|
||||
type: 'number',
|
||||
label: 'Фасовка красителя, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'dye',
|
||||
},
|
||||
{
|
||||
id: 'moldPrice',
|
||||
type: 'number',
|
||||
label: 'Стоимость формы/банки, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
groupName: 'mold',
|
||||
showStepAfter: 'mold',
|
||||
},
|
||||
{
|
||||
id: 'packaging',
|
||||
type: 'number',
|
||||
label: 'Упаковка, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
groupName: 'packaging',
|
||||
showStepAfter: 'packaging',
|
||||
},
|
||||
{
|
||||
id: 'markup',
|
||||
type: 'number',
|
||||
label: 'Наценка, %',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
|
||||
calculationSteps: [
|
||||
{
|
||||
id: 'wax',
|
||||
name: 'Себестоимость воска',
|
||||
formula: (values) => round(calculateCandleStep('wax', values)),
|
||||
formulaDescription: '(вес_воска / 1000) * цена_воска',
|
||||
},
|
||||
{
|
||||
id: 'wick',
|
||||
name: 'Себестоимость фитилей',
|
||||
formula: (values) => round(calculateCandleStep('wick', values)),
|
||||
formulaDescription: 'количество_фитилей * цена_фитиля',
|
||||
},
|
||||
{
|
||||
id: 'fragrance',
|
||||
name: 'Себестоимость отдушки (10 %)',
|
||||
formula: (values) => round(calculateCandleStep('fragrance', values)),
|
||||
formulaDescription: '((вес_воска * 0.10) / фасовка_отдушки) * цена_отдушки',
|
||||
},
|
||||
{
|
||||
id: 'dye',
|
||||
name: 'Себестоимость красителя (1 %)',
|
||||
formula: (values) => round(calculateCandleStep('dye', values)),
|
||||
formulaDescription: '((вес_воска * 0.01) / фасовка_красителя) * цена_красителя',
|
||||
},
|
||||
{
|
||||
id: 'mold',
|
||||
name: 'Стоимость формы/банки',
|
||||
formula: (values) => {
|
||||
const moldPrice = values.moldPrice || 0;
|
||||
// Предположим, форма рассчитана на 100 использований
|
||||
return round(moldPrice / 100);
|
||||
},
|
||||
formulaDescription: 'стоимость_формы / 100',
|
||||
},
|
||||
{
|
||||
id: 'packaging',
|
||||
name: 'Стоимость упаковки',
|
||||
formula: (values) => {
|
||||
const packaging = values.packaging || 0;
|
||||
return round(packaging);
|
||||
},
|
||||
formulaDescription: 'стоимость_упаковки',
|
||||
},
|
||||
],
|
||||
|
||||
subtotals: [
|
||||
{
|
||||
id: 'operational',
|
||||
name: 'Операционные расходы (5 %)',
|
||||
formula: (values, steps) => round(calculateCandleSubtotal('operational', values, steps)),
|
||||
formulaDescription: '(воск + фитили + отдушка + краситель + форма + упаковка) * 0.05',
|
||||
},
|
||||
{
|
||||
id: 'total',
|
||||
name: 'Итого себестоимость',
|
||||
formula: (values, steps) => round(calculateCandleSubtotal('total', values, steps)),
|
||||
highlight: true,
|
||||
formulaDescription: 'воск + фитили + отдушка + краситель + форма + упаковка + операционные',
|
||||
},
|
||||
],
|
||||
|
||||
additionalCalculations: [
|
||||
{
|
||||
id: 'finalPrice',
|
||||
name: 'Итоговая цена с наценкой',
|
||||
formula: (values, steps, subtotals) => {
|
||||
const total = subtotals.total || 0;
|
||||
const markup = values.markup || 0;
|
||||
return round(total * (1 + markup / 100));
|
||||
},
|
||||
formulaDescription: 'итого_себестоимость * (1 + наценка / 100)',
|
||||
},
|
||||
{
|
||||
id: 'pricePer100g',
|
||||
name: 'Цена за 100 г',
|
||||
formula: (values, steps, subtotals, additional) => {
|
||||
const weight = values.waxWeight || 0;
|
||||
const finalPrice = additional?.finalPrice || 0;
|
||||
if (weight > 0) {
|
||||
return round((finalPrice / weight) * 100);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
formulaDescription: '(итоговая_цена / вес_воска) * 100',
|
||||
},
|
||||
],
|
||||
|
||||
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||
const candleName = values.candleName || 'Без названия';
|
||||
const waxWeight = values.waxWeight || 0;
|
||||
const wickCount = values.wickCount || 0;
|
||||
const packaging = values.packaging || 0;
|
||||
const markup = values.markup || 0;
|
||||
const totalCost = subtotals.total || 0;
|
||||
const finalPrice = additional?.finalPrice || 0;
|
||||
const pricePer100g = additional?.pricePer100g || 0;
|
||||
|
||||
let text = `🕯️ <b>Расчёт свечи:</b> <i>${candleName}</i>\n\n`;
|
||||
text += `⚖️ <b>Вес воска:</b> ${waxWeight} г\n`;
|
||||
text += `🕯️ <b>Количество фитилей:</b> ${wickCount} шт\n`;
|
||||
text += `📦 <b>Упаковка:</b> ${packaging} ₽\n\n`;
|
||||
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||
text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
|
||||
text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`;
|
||||
text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`;
|
||||
|
||||
return text;
|
||||
},
|
||||
};
|
||||
|
||||
79
frontend/calculators/soap/calc.ts
Normal file
79
frontend/calculators/soap/calc.ts
Normal file
@ -0,0 +1,79 @@
|
||||
// Функции расчета для калькулятора мыла
|
||||
|
||||
export function calculateSoapStep(
|
||||
stepId: string,
|
||||
values: Record<string, number>
|
||||
): number {
|
||||
const {
|
||||
weight = 0,
|
||||
basePrice = 0,
|
||||
aromaPrice = 0,
|
||||
aromaWeight = 0,
|
||||
pigmentPrice = 0,
|
||||
pigmentWeight = 0,
|
||||
moldPrice = 0,
|
||||
box = 0,
|
||||
filler = 0,
|
||||
ribbon = 0,
|
||||
label = 0,
|
||||
} = values;
|
||||
|
||||
switch (stepId) {
|
||||
case 'base':
|
||||
if (weight <= 0 || basePrice <= 0) return 0;
|
||||
return (weight / 1000) * basePrice;
|
||||
|
||||
case 'aroma':
|
||||
if (weight <= 0 || aromaWeight <= 0 || aromaPrice <= 0) return 0;
|
||||
return ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||
|
||||
case 'pigment':
|
||||
if (weight <= 0 || pigmentWeight <= 0 || pigmentPrice <= 0) return 0;
|
||||
return ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||
|
||||
case 'mold':
|
||||
if (moldPrice <= 0) return 0;
|
||||
return moldPrice / 100;
|
||||
|
||||
case 'packaging':
|
||||
return (box || 0) + (filler || 0) + (ribbon || 0) + (label || 0);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateSoapSubtotal(
|
||||
subtotalId: string,
|
||||
values: Record<string, number>,
|
||||
steps: Record<string, number>
|
||||
): number {
|
||||
switch (subtotalId) {
|
||||
case 'operational':
|
||||
const subtotal =
|
||||
(steps.base || 0) +
|
||||
(steps.aroma || 0) +
|
||||
(steps.pigment || 0) +
|
||||
(steps.mold || 0) +
|
||||
(steps.packaging || 0);
|
||||
return subtotal * 0.05;
|
||||
|
||||
case 'total':
|
||||
return (
|
||||
(steps.base || 0) +
|
||||
(steps.aroma || 0) +
|
||||
(steps.pigment || 0) +
|
||||
(steps.mold || 0) +
|
||||
(steps.packaging || 0) +
|
||||
(steps.operational || 0)
|
||||
);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function round(val: number): number {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
|
||||
246
frontend/calculators/soap/config.ts
Normal file
246
frontend/calculators/soap/config.ts
Normal file
@ -0,0 +1,246 @@
|
||||
// Конфигурация калькулятора мыла
|
||||
import { CalculatorConfig } from '@/lib/calculator-types';
|
||||
import { calculateSoapStep, calculateSoapSubtotal, round } from './calc';
|
||||
|
||||
export const soapCalculatorConfig: CalculatorConfig = {
|
||||
id: 'soap',
|
||||
name: 'Калькулятор мыла',
|
||||
description: 'Расчет себестоимости мыла ручной работы',
|
||||
icon: '🧼',
|
||||
|
||||
fields: [
|
||||
{
|
||||
id: 'soapName',
|
||||
type: 'text',
|
||||
label: 'Название мыла',
|
||||
placeholder: 'Введите название',
|
||||
defaultValue: '',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'photo',
|
||||
type: 'file',
|
||||
label: 'Фото мыла (необязательно)',
|
||||
accept: 'image/*',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
type: 'number',
|
||||
label: 'Вес мыла, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'base',
|
||||
showStepAfter: 'base',
|
||||
},
|
||||
{
|
||||
id: 'basePrice',
|
||||
type: 'number',
|
||||
label: 'Цена основы, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'base',
|
||||
},
|
||||
{
|
||||
id: 'aromaPrice',
|
||||
type: 'number',
|
||||
label: 'Цена отдушки, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'aroma',
|
||||
showStepAfter: 'aroma',
|
||||
},
|
||||
{
|
||||
id: 'aromaWeight',
|
||||
type: 'number',
|
||||
label: 'Фасовка отдушки, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'aroma',
|
||||
},
|
||||
{
|
||||
id: 'pigmentPrice',
|
||||
type: 'number',
|
||||
label: 'Цена пигмента, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'pigment',
|
||||
showStepAfter: 'pigment',
|
||||
},
|
||||
{
|
||||
id: 'pigmentWeight',
|
||||
type: 'number',
|
||||
label: 'Фасовка пигмента, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'pigment',
|
||||
},
|
||||
{
|
||||
id: 'moldPrice',
|
||||
type: 'number',
|
||||
label: 'Цена формы, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
groupName: 'mold',
|
||||
showStepAfter: 'mold',
|
||||
},
|
||||
{
|
||||
id: 'box',
|
||||
type: 'number',
|
||||
label: 'Пакет/коробка, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'packaging',
|
||||
showStepAfter: 'packaging',
|
||||
},
|
||||
{
|
||||
id: 'filler',
|
||||
type: 'number',
|
||||
label: 'Наполнитель, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'packaging',
|
||||
},
|
||||
{
|
||||
id: 'ribbon',
|
||||
type: 'number',
|
||||
label: 'Лента, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'packaging',
|
||||
},
|
||||
{
|
||||
id: 'label',
|
||||
type: 'number',
|
||||
label: 'Наклейка, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
groupName: 'packaging',
|
||||
},
|
||||
{
|
||||
id: 'markup',
|
||||
type: 'number',
|
||||
label: 'Наценка, %',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
|
||||
calculationSteps: [
|
||||
{
|
||||
id: 'base',
|
||||
name: 'Себестоимость основы',
|
||||
formula: (values) => round(calculateSoapStep('base', values)),
|
||||
formulaDescription: '(вес / 1000) * цена_основы',
|
||||
},
|
||||
{
|
||||
id: 'aroma',
|
||||
name: 'Себестоимость отдушки (1 %)',
|
||||
formula: (values) => round(calculateSoapStep('aroma', values)),
|
||||
formulaDescription: '((вес * 0.01) / фасовка_отдушки) * цена_отдушки',
|
||||
},
|
||||
{
|
||||
id: 'pigment',
|
||||
name: 'Себестоимость пигмента (0.5 %)',
|
||||
formula: (values) => round(calculateSoapStep('pigment', values)),
|
||||
formulaDescription: '((вес * 0.005) / фасовка_пигмента) * цена_пигмента',
|
||||
},
|
||||
{
|
||||
id: 'mold',
|
||||
name: 'Себестоимость формы',
|
||||
formula: (values) => round(calculateSoapStep('mold', values)),
|
||||
formulaDescription: 'цена_формы / 100',
|
||||
},
|
||||
{
|
||||
id: 'packaging',
|
||||
name: 'Стоимость упаковки',
|
||||
formula: (values) => round(calculateSoapStep('packaging', values)),
|
||||
formulaDescription: 'пакет + наполнитель + лента + наклейка',
|
||||
},
|
||||
],
|
||||
|
||||
subtotals: [
|
||||
{
|
||||
id: 'operational',
|
||||
name: 'Операционные расходы (5 %)',
|
||||
formula: (values, steps) => round(calculateSoapSubtotal('operational', values, steps)),
|
||||
formulaDescription: '(основа + отдушка + пигмент + форма + упаковка) * 0.05',
|
||||
},
|
||||
{
|
||||
id: 'total',
|
||||
name: 'Итого себестоимость',
|
||||
formula: (values, steps) => round(calculateSoapSubtotal('total', values, steps)),
|
||||
highlight: true,
|
||||
formulaDescription: 'основа + отдушка + пигмент + форма + упаковка + операционные',
|
||||
},
|
||||
],
|
||||
|
||||
additionalCalculations: [
|
||||
{
|
||||
id: 'finalPrice',
|
||||
name: 'Итоговая цена с наценкой',
|
||||
formula: (values, steps, subtotals) => {
|
||||
const total = subtotals.total || 0;
|
||||
const markup = values.markup || 0;
|
||||
return round(total * (1 + markup / 100));
|
||||
},
|
||||
formulaDescription: 'итого_себестоимость * (1 + наценка / 100)',
|
||||
},
|
||||
{
|
||||
id: 'pricePer100g',
|
||||
name: 'Цена за 100 г',
|
||||
formula: (values, steps, subtotals, additional) => {
|
||||
const weight = values.weight || 0;
|
||||
const finalPrice = additional?.finalPrice || 0;
|
||||
if (weight > 0) {
|
||||
return round((finalPrice / weight) * 100);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
formulaDescription: '(итоговая_цена / вес) * 100',
|
||||
},
|
||||
],
|
||||
|
||||
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||
const soapName = values.soapName || 'Без названия';
|
||||
const weight = values.weight || 0;
|
||||
const box = values.box || 0;
|
||||
const filler = values.filler || 0;
|
||||
const ribbon = values.ribbon || 0;
|
||||
const label = values.label || 0;
|
||||
const markup = values.markup || 0;
|
||||
const totalCost = subtotals.total || 0;
|
||||
const finalPrice = additional?.finalPrice || 0;
|
||||
const pricePer100g = additional?.pricePer100g || 0;
|
||||
|
||||
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
||||
text += `⚖️ <b>Вес мыла:</b> ${weight} г\n\n`;
|
||||
text += `📦 <b>Упаковка:</b>\n`;
|
||||
text += ` 📥 Пакет/коробка: ${box} ₽\n`;
|
||||
text += ` 🌾 Наполнитель: ${filler} ₽\n`;
|
||||
text += ` 🎀 Лента: ${ribbon} ₽\n`;
|
||||
text += ` 🏷️ Наклейка: ${label} ₽\n\n`;
|
||||
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||
text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
|
||||
text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`;
|
||||
text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`;
|
||||
|
||||
return text;
|
||||
},
|
||||
};
|
||||
|
||||
550
frontend/components/CalculatorEngine.tsx
Normal file
550
frontend/components/CalculatorEngine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
108
frontend/components/CalculatorMenu.tsx
Normal file
108
frontend/components/CalculatorMenu.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { getAllCalculators } from '@/lib/calculator-registry';
|
||||
import CalculatorEngine from './CalculatorEngine';
|
||||
|
||||
type CalculatorMenuProps = {
|
||||
chatId?: string | null;
|
||||
};
|
||||
|
||||
export default function CalculatorMenu({ chatId: externalChatId }: CalculatorMenuProps) {
|
||||
const [selectedCalculator, setSelectedCalculator] = useState<string | null>(null);
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const calculators = getAllCalculators();
|
||||
|
||||
// Получаем chat_id из URL или пропсов
|
||||
useEffect(() => {
|
||||
if (externalChatId) {
|
||||
setChatId(externalChatId);
|
||||
} else {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const id = params.get('chat_id');
|
||||
if (id) {
|
||||
setChatId(id);
|
||||
}
|
||||
}
|
||||
}, [externalChatId]);
|
||||
|
||||
// Если выбран калькулятор, показываем его
|
||||
if (selectedCalculator) {
|
||||
return (
|
||||
<CalculatorEngine
|
||||
calculatorId={selectedCalculator}
|
||||
chatId={chatId}
|
||||
onBack={() => setSelectedCalculator(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Иначе показываем меню выбора
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg shadow-lg">
|
||||
{/* Логотип */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
width={150}
|
||||
height={50}
|
||||
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chatId === null && (
|
||||
<div className="bg-yellow-800 p-4 text-yellow-200 font-semibold rounded mb-6">
|
||||
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Заголовок */}
|
||||
<h1 className="text-2xl font-bold text-center mb-8">
|
||||
Выберите калькулятор
|
||||
</h1>
|
||||
|
||||
{/* Список калькуляторов */}
|
||||
{calculators.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Нет доступных калькуляторов
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{calculators.map((calc) => (
|
||||
<button
|
||||
key={calc.id}
|
||||
onClick={() => setSelectedCalculator(calc.id)}
|
||||
className="
|
||||
p-6
|
||||
bg-gray-700
|
||||
hover:bg-gray-600
|
||||
rounded-lg
|
||||
border-2 border-gray-600
|
||||
hover:border-sky-500
|
||||
transition-all
|
||||
text-left
|
||||
focus:outline-none focus:ring-2 focus:ring-sky-500
|
||||
"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{calc.icon && <span className="text-3xl">{calc.icon}</span>}
|
||||
<h2 className="text-xl font-semibold">{calc.name}</h2>
|
||||
</div>
|
||||
{calc.description && (
|
||||
<p className="text-gray-400 text-sm">{calc.description}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка */}
|
||||
<div className="mt-8 text-center text-gray-400 text-sm">
|
||||
Выберите калькулятор для начала расчёта
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,397 +0,0 @@
|
||||
// components/SoapCalculator.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { calculateTotal } from '@/lib/calc';
|
||||
|
||||
type InputNumberProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
|
||||
const InputNumber = ({ label, value, onChange }: InputNumberProps) => {
|
||||
const id = label.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<div className="relative mt-6">
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder=" "
|
||||
className={`
|
||||
peer
|
||||
h-10 w-full
|
||||
bg-gray-700
|
||||
border-2 border-gray-600
|
||||
rounded-md
|
||||
text-gray-200
|
||||
placeholder-transparent
|
||||
pl-3
|
||||
focus:outline-none focus:border-sky-500
|
||||
appearance-none
|
||||
`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`
|
||||
absolute left-3
|
||||
-top-5
|
||||
text-gray-400
|
||||
transition-all
|
||||
/* активное состояние: адаптивный размер шрифта */
|
||||
text-xs sm:text-sm md:text-base lg:text-lg
|
||||
/* когда поле пустое: слегка больше (помещается внутри) */
|
||||
peer-placeholder-shown:top-2
|
||||
peer-placeholder-shown:text-gray-500
|
||||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||
/* при фокусе или если есть содержимое: сжатый текст */
|
||||
peer-focus:-top-5
|
||||
peer-focus:text-gray-200
|
||||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SoapCalculator() {
|
||||
const [soapName, setSoapName] = useState('');
|
||||
const [weight, setWeight] = useState('');
|
||||
const [basePrice, setBasePrice] = useState('');
|
||||
const [aromaPrice, setAromaPrice] = useState('');
|
||||
const [aromaWeight, setAromaWeight] = useState('');
|
||||
const [pigmentPrice, setPigmentPrice] = useState('');
|
||||
const [pigmentWeight, setPigmentWeight] = useState('');
|
||||
const [moldPrice, setMoldPrice] = useState('');
|
||||
const [box, setBox] = useState('');
|
||||
const [filler, setFiller] = useState('');
|
||||
const [ribbon, setRibbon] = useState('');
|
||||
const [labelValue, setLabelValue] = useState('');
|
||||
const [markup, setMarkup] = useState('');
|
||||
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const id = params.get('chat_id');
|
||||
if (id) {
|
||||
setChatId(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toNum = (str: string) => {
|
||||
const n = parseFloat(str.replace(',', '.'));
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
const weightNum = toNum(weight);
|
||||
const basePriceNum = toNum(basePrice);
|
||||
const aromaPriceNum = toNum(aromaPrice);
|
||||
const aromaWeightNum = toNum(aromaWeight);
|
||||
const pigmentPriceNum = toNum(pigmentPrice);
|
||||
const pigmentWeightNum = toNum(pigmentWeight);
|
||||
const moldPriceNum = toNum(moldPrice);
|
||||
const boxNum = toNum(box);
|
||||
const fillerNum = toNum(filler);
|
||||
const ribbonNum = toNum(ribbon);
|
||||
const labelNum = toNum(labelValue);
|
||||
const markupNum = toNum(markup);
|
||||
|
||||
const result = calculateTotal({
|
||||
weight: weightNum,
|
||||
basePrice: basePriceNum,
|
||||
aromaPrice: aromaPriceNum,
|
||||
aromaWeight: aromaWeightNum,
|
||||
pigmentPrice: pigmentPriceNum,
|
||||
pigmentWeight: pigmentWeightNum,
|
||||
moldPrice: moldPriceNum,
|
||||
packaging: {
|
||||
box: boxNum,
|
||||
filler: fillerNum,
|
||||
ribbon: ribbonNum,
|
||||
label: labelNum,
|
||||
},
|
||||
});
|
||||
|
||||
const finalPrice = result.total * (1 + markupNum / 100);
|
||||
const pricePer100g = weightNum > 0 ? (finalPrice / weightNum) * 100 : 0;
|
||||
|
||||
const handlePhotoChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setPhotoFile(e.target.files[0]);
|
||||
} else {
|
||||
setPhotoFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!chatId) {
|
||||
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('chat_id', chatId);
|
||||
formData.append('soapName', soapName || '');
|
||||
formData.append('weight', weightNum.toString());
|
||||
formData.append('basePrice', basePriceNum.toString());
|
||||
formData.append('aromaPrice', aromaPriceNum.toString());
|
||||
formData.append('aromaWeight', aromaWeightNum.toString());
|
||||
formData.append('pigmentPrice', pigmentPriceNum.toString());
|
||||
formData.append('pigmentWeight', pigmentWeightNum.toString());
|
||||
formData.append('moldPrice', moldPriceNum.toString());
|
||||
formData.append('box', boxNum.toString());
|
||||
formData.append('filler', fillerNum.toString());
|
||||
formData.append('ribbon', ribbonNum.toString());
|
||||
formData.append('labelValue', labelNum.toString());
|
||||
formData.append('markup', markupNum.toString());
|
||||
formData.append('totalCost', result.total.toString());
|
||||
formData.append('finalPrice', finalPrice.toString());
|
||||
formData.append('pricePer100g', pricePer100g.toString());
|
||||
|
||||
if (photoFile) {
|
||||
formData.append('photo', photoFile);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api-dosoap.duckdns.org/api/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('✅ Расчёт успешно отправлен в Telegram!');
|
||||
setSoapName('');
|
||||
setWeight('');
|
||||
setBasePrice('');
|
||||
setAromaPrice('');
|
||||
setAromaWeight('');
|
||||
setPigmentPrice('');
|
||||
setPigmentWeight('');
|
||||
setMoldPrice('');
|
||||
setBox('');
|
||||
setFiller('');
|
||||
setRibbon('');
|
||||
setLabelValue('');
|
||||
setMarkup('');
|
||||
setPhotoFile(null);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert(`Ошибка при отправке: ${text}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Ошибка сети при отправке расчёта');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="
|
||||
max-w-xl mx-auto p-6 space-y-6
|
||||
bg-gray-800 text-gray-200
|
||||
rounded-lg shadow-lg
|
||||
"
|
||||
>
|
||||
{/* Центрированный адаптивный логотип */}
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
width={150}
|
||||
height={50}
|
||||
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chatId === null && (
|
||||
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
|
||||
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Название мыла */}
|
||||
<div className="relative mt-6">
|
||||
<input
|
||||
id="soap-name"
|
||||
type="text"
|
||||
value={soapName}
|
||||
onChange={(e) => setSoapName(e.target.value)}
|
||||
placeholder=" "
|
||||
className={`
|
||||
peer
|
||||
h-10 w-full
|
||||
bg-gray-700
|
||||
border-2 border-gray-600
|
||||
rounded-md
|
||||
text-gray-200
|
||||
placeholder-transparent
|
||||
pl-3
|
||||
focus:outline-none focus:border-sky-500
|
||||
appearance-none
|
||||
`}
|
||||
/>
|
||||
<label
|
||||
htmlFor="soap-name"
|
||||
className={`
|
||||
absolute left-3
|
||||
-top-5
|
||||
text-gray-400
|
||||
transition-all
|
||||
/* активное состояние: адаптивный размер шрифта */
|
||||
text-xs sm:text-sm md:text-base lg:text-lg
|
||||
/* когда поле пустое: немного крупнее, помещается внутри */
|
||||
peer-placeholder-shown:top-2
|
||||
peer-placeholder-shown:text-gray-500
|
||||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||
/* при фокусе/заполненном поле: адаптивный размер */
|
||||
peer-focus:-top-5
|
||||
peer-focus:text-gray-200
|
||||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||
`}
|
||||
>
|
||||
Название мыла
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Фото мыла */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-gray-300">Фото мыла (необязательно)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoChange}
|
||||
className="
|
||||
bg-gray-700
|
||||
text-gray-200
|
||||
rounded-md
|
||||
border border-gray-600
|
||||
p-2
|
||||
focus:outline-none focus:border-sky-500
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
{photoFile && (
|
||||
<img
|
||||
src={URL.createObjectURL(photoFile)}
|
||||
alt="Предпросмотр мыла"
|
||||
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Блок «Основа» */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputNumber label="Вес мыла, г" value={weight} onChange={setWeight} />
|
||||
<InputNumber
|
||||
label="Цена основы, руб"
|
||||
value={basePrice}
|
||||
onChange={setBasePrice}
|
||||
/>
|
||||
</div>
|
||||
<CostBlock title="Себестоимость основы" value={result.base} />
|
||||
|
||||
{/* Блок «Отдушка» */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputNumber
|
||||
label="Цена отдушки, руб"
|
||||
value={aromaPrice}
|
||||
onChange={setAromaPrice}
|
||||
/>
|
||||
<InputNumber
|
||||
label="Фасовка отдушки, г"
|
||||
value={aromaWeight}
|
||||
onChange={setAromaWeight}
|
||||
/>
|
||||
</div>
|
||||
<CostBlock title="Себестоимость отдушки (1 %)" value={result.aroma} />
|
||||
|
||||
{/* Блок «Пигмент» */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputNumber
|
||||
label="Цена пигмента, руб"
|
||||
value={pigmentPrice}
|
||||
onChange={setPigmentPrice}
|
||||
/>
|
||||
<InputNumber
|
||||
label="Фасовка пигмента, г"
|
||||
value={pigmentWeight}
|
||||
onChange={setPigmentWeight}
|
||||
/>
|
||||
</div>
|
||||
<CostBlock title="Себестоимость пигмента (0.5 %)" value={result.pigment} />
|
||||
|
||||
{/* Блок «Форма» */}
|
||||
<InputNumber
|
||||
label="Цена формы, руб"
|
||||
value={moldPrice}
|
||||
onChange={setMoldPrice}
|
||||
/>
|
||||
<CostBlock title="Себестоимость формы" value={result.mold} />
|
||||
|
||||
{/* Блок «Упаковка» */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputNumber label="Пакет/коробка, руб" value={box} onChange={setBox} />
|
||||
<InputNumber label="Наполнитель, руб" value={filler} onChange={setFiller} />
|
||||
<InputNumber label="Лента, руб" value={ribbon} onChange={setRibbon} />
|
||||
<InputNumber
|
||||
label="Наклейка, руб"
|
||||
value={labelValue}
|
||||
onChange={setLabelValue}
|
||||
/>
|
||||
</div>
|
||||
<CostBlock title="Стоимость упаковки" value={result.packaging} />
|
||||
<CostBlock title="Операционные расходы (5 %)" value={result.operational} />
|
||||
<CostBlock title="Итого себестоимость" value={result.total} highlight />
|
||||
|
||||
{/* Блок «Наценка и цена 100 г» */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputNumber label="Наценка, %" value={markup} onChange={setMarkup} />
|
||||
</div>
|
||||
<CostBlock title="Итоговая цена с наценкой" value={finalPrice} />
|
||||
<CostBlock title="Цена за 100 г" value={pricePer100g} />
|
||||
|
||||
{/* Кнопка «Отправить расчёт» */}
|
||||
<button
|
||||
type="submit"
|
||||
className="
|
||||
w-full
|
||||
py-2
|
||||
rounded-md
|
||||
bg-sky-500 hover:bg-sky-600
|
||||
text-gray-100 font-semibold
|
||||
focus:outline-none focus:ring focus:ring-offset-2 focus:ring-sky-500 focus:ring-opacity-60
|
||||
"
|
||||
>
|
||||
Отправить расчёт в Telegram
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type CostBlockProps = {
|
||||
title: string;
|
||||
value: number;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
const CostBlock = ({ title, value, highlight = false }: CostBlockProps) => (
|
||||
<div
|
||||
className={`
|
||||
p-2
|
||||
${highlight ? 'bg-gray-600 font-semibold' : 'bg-gray-700'}
|
||||
text-gray-200
|
||||
rounded
|
||||
`}
|
||||
>
|
||||
{title}: {value.toFixed(1)} руб
|
||||
</div>
|
||||
);
|
||||
386
frontend/docs/calculator-creation-guide.md
Normal file
386
frontend/docs/calculator-creation-guide.md
Normal file
@ -0,0 +1,386 @@
|
||||
# Руководство по созданию новых калькуляторов
|
||||
|
||||
Это руководство поможет вам быстро создать новый калькулятор в модульной системе.
|
||||
|
||||
## Структура модуля калькулятора
|
||||
|
||||
Каждый калькулятор состоит из двух файлов в папке `frontend/calculators/[название]/`:
|
||||
|
||||
- `config.ts` — конфигурация калькулятора (поля, формулы, подитоги)
|
||||
- `calc.ts` — функции расчета (опционально, если нужны сложные вычисления)
|
||||
|
||||
## Шаг 1: Создание папки модуля
|
||||
|
||||
Создайте папку для вашего калькулятора:
|
||||
|
||||
```
|
||||
frontend/calculators/candles/
|
||||
```
|
||||
|
||||
## Шаг 2: Создание файла calc.ts (если нужны функции расчета)
|
||||
|
||||
Если у вас простые формулы, можно обойтись без этого файла и указать формулы прямо в конфигурации.
|
||||
|
||||
Пример для калькулятора свечей:
|
||||
|
||||
```typescript
|
||||
// calculators/candles/calc.ts
|
||||
|
||||
export function calculateCandleStep(
|
||||
stepId: string,
|
||||
values: Record<string, number>
|
||||
): number {
|
||||
const { waxWeight = 0, waxPrice = 0, wickCount = 0, wickPrice = 0 } = values;
|
||||
|
||||
switch (stepId) {
|
||||
case 'wax':
|
||||
return (waxWeight / 1000) * waxPrice;
|
||||
|
||||
case 'wick':
|
||||
return wickCount * wickPrice;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function round(val: number): number {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
```
|
||||
|
||||
## Шаг 3: Создание файла config.ts
|
||||
|
||||
Это основной файл конфигурации калькулятора:
|
||||
|
||||
```typescript
|
||||
// calculators/candles/config.ts
|
||||
|
||||
import { CalculatorConfig } from '@/lib/calculator-types';
|
||||
import { calculateCandleStep, round } from './calc';
|
||||
|
||||
export const candlesCalculatorConfig: CalculatorConfig = {
|
||||
id: 'candles',
|
||||
name: 'Калькулятор свечей',
|
||||
description: 'Расчет себестоимости свечей',
|
||||
icon: '🕯️',
|
||||
|
||||
// Поля ввода
|
||||
fields: [
|
||||
{
|
||||
id: 'candleName',
|
||||
type: 'text',
|
||||
label: 'Название свечи',
|
||||
placeholder: 'Введите название',
|
||||
defaultValue: '',
|
||||
gridCols: 1,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'waxWeight',
|
||||
type: 'number',
|
||||
label: 'Вес воска, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
},
|
||||
{
|
||||
id: 'waxPrice',
|
||||
type: 'number',
|
||||
label: 'Цена воска за 1 кг, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
},
|
||||
{
|
||||
id: 'wickCount',
|
||||
type: 'number',
|
||||
label: 'Количество фитилей',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
},
|
||||
{
|
||||
id: 'wickPrice',
|
||||
type: 'number',
|
||||
label: 'Цена одного фитиля, руб',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
},
|
||||
],
|
||||
|
||||
// Шаги расчета
|
||||
calculationSteps: [
|
||||
{
|
||||
id: 'wax',
|
||||
name: 'Себестоимость воска',
|
||||
formula: (values) => round(calculateCandleStep('wax', values)),
|
||||
formulaDescription: '(вес_воска / 1000) * цена_воска',
|
||||
},
|
||||
{
|
||||
id: 'wick',
|
||||
name: 'Себестоимость фитилей',
|
||||
formula: (values) => round(calculateCandleStep('wick', values)),
|
||||
formulaDescription: 'количество_фитилей * цена_фитиля',
|
||||
},
|
||||
],
|
||||
|
||||
// Подитоги
|
||||
subtotals: [
|
||||
{
|
||||
id: 'total',
|
||||
name: 'Итого себестоимость',
|
||||
formula: (values, steps) => {
|
||||
return round((steps.wax || 0) + (steps.wick || 0));
|
||||
},
|
||||
highlight: true,
|
||||
formulaDescription: 'воск + фитили',
|
||||
},
|
||||
],
|
||||
|
||||
// Дополнительные расчеты (опционально)
|
||||
additionalCalculations: [
|
||||
{
|
||||
id: 'pricePerCandle',
|
||||
name: 'Цена за свечу',
|
||||
formula: (values, steps, subtotals) => {
|
||||
const total = subtotals.total || 0;
|
||||
return round(total);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Форматирование сообщения для Telegram (опционально)
|
||||
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||
const candleName = values.candleName || 'Без названия';
|
||||
const totalCost = subtotals.total || 0;
|
||||
|
||||
let text = `🕯️ <b>Расчёт свечи:</b> <i>${candleName}</i>\n\n`;
|
||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||
text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
|
||||
|
||||
return text;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Шаг 4: Регистрация калькулятора
|
||||
|
||||
Откройте файл `frontend/lib/calculator-registry.ts` и добавьте регистрацию:
|
||||
|
||||
```typescript
|
||||
import { candlesCalculatorConfig } from '@/calculators/candles/config';
|
||||
|
||||
// В функции initializeCalculators():
|
||||
registerCalculator(candlesCalculatorConfig);
|
||||
```
|
||||
|
||||
## Типы полей
|
||||
|
||||
### text
|
||||
Текстовое поле ввода:
|
||||
```typescript
|
||||
{
|
||||
id: 'productName',
|
||||
type: 'text',
|
||||
label: 'Название продукта',
|
||||
defaultValue: '',
|
||||
gridCols: 1, // 1 или 2 колонки
|
||||
}
|
||||
```
|
||||
|
||||
### number
|
||||
Числовое поле ввода:
|
||||
```typescript
|
||||
{
|
||||
id: 'weight',
|
||||
type: 'number',
|
||||
label: 'Вес, г',
|
||||
defaultValue: '',
|
||||
gridCols: 2,
|
||||
validation: {
|
||||
min: 0,
|
||||
max: 10000,
|
||||
},
|
||||
// Группировка полей (опционально)
|
||||
groupName: 'base', // Имя группы для группировки связанных полей
|
||||
showStepAfter: 'base', // ID шага расчета, который показать после этой группы
|
||||
}
|
||||
```
|
||||
|
||||
### file
|
||||
Поле для загрузки файла (обычно фото):
|
||||
```typescript
|
||||
{
|
||||
id: 'photo',
|
||||
type: 'file',
|
||||
label: 'Фото продукта',
|
||||
accept: 'image/*',
|
||||
gridCols: 1,
|
||||
}
|
||||
```
|
||||
|
||||
## Группировка полей
|
||||
|
||||
Для правильного расположения блоков расчета сразу после соответствующих полей используйте группировку:
|
||||
|
||||
```typescript
|
||||
fields: [
|
||||
// Группа "base" - после этих полей покажется расчет "base"
|
||||
{
|
||||
id: 'weight',
|
||||
type: 'number',
|
||||
label: 'Вес, г',
|
||||
groupName: 'base',
|
||||
showStepAfter: 'base', // Первое поле группы должно иметь showStepAfter
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
type: 'number',
|
||||
label: 'Цена, руб',
|
||||
groupName: 'base', // Та же группа
|
||||
},
|
||||
// Блок расчета "base" автоматически появится здесь
|
||||
|
||||
// Группа "packaging"
|
||||
{
|
||||
id: 'box',
|
||||
type: 'number',
|
||||
label: 'Коробка, руб',
|
||||
groupName: 'packaging',
|
||||
showStepAfter: 'packaging',
|
||||
},
|
||||
{
|
||||
id: 'ribbon',
|
||||
type: 'number',
|
||||
label: 'Лента, руб',
|
||||
groupName: 'packaging',
|
||||
},
|
||||
// Блок расчета "packaging" автоматически появится здесь
|
||||
]
|
||||
```
|
||||
|
||||
## Формулы расчета
|
||||
|
||||
Формулы — это функции, которые принимают значения полей и возвращают число:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'baseCost',
|
||||
name: 'Базовая стоимость',
|
||||
formula: (values) => {
|
||||
const weight = values.weight || 0;
|
||||
const price = values.price || 0;
|
||||
return weight * price;
|
||||
},
|
||||
formulaDescription: 'вес * цена', // Опционально, для документации
|
||||
}
|
||||
```
|
||||
|
||||
В формуле доступны все значения полей через объект `values`, где ключ — это `id` поля.
|
||||
|
||||
## Подитоги
|
||||
|
||||
Подитоги могут зависеть от шагов расчета:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'total',
|
||||
name: 'Итого',
|
||||
formula: (values, steps) => {
|
||||
// values — значения полей
|
||||
// steps — результаты всех шагов расчета
|
||||
return (steps.base || 0) + (steps.packaging || 0);
|
||||
},
|
||||
highlight: true, // Выделить итоговое значение
|
||||
}
|
||||
```
|
||||
|
||||
## Дополнительные расчеты
|
||||
|
||||
Используются для расчетов, которые зависят от подитогов или других дополнительных расчетов:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'finalPrice',
|
||||
name: 'Итоговая цена',
|
||||
formula: (values, steps, subtotals, additional) => {
|
||||
const total = subtotals.total || 0;
|
||||
const markup = values.markup || 0;
|
||||
return total * (1 + markup / 100);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Важно**: Дополнительные расчеты выполняются последовательно. Если один расчет зависит от другого, расположите зависимый расчет **после** того, от которого он зависит:
|
||||
|
||||
```typescript
|
||||
additionalCalculations: [
|
||||
{
|
||||
id: 'finalPrice',
|
||||
name: 'Итоговая цена с наценкой',
|
||||
formula: (values, steps, subtotals) => {
|
||||
const total = subtotals.total || 0;
|
||||
const markup = values.markup || 0;
|
||||
return total * (1 + markup / 100);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pricePer100g',
|
||||
name: 'Цена за 100 г',
|
||||
formula: (values, steps, subtotals, additional) => {
|
||||
// Используем уже рассчитанный finalPrice из additional
|
||||
const finalPrice = additional?.finalPrice || 0;
|
||||
const weight = values.weight || 0;
|
||||
if (weight > 0) {
|
||||
return (finalPrice / weight) * 100;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## Форматирование сообщения для Telegram
|
||||
|
||||
Если не указать `formatTelegramMessage`, будет использовано универсальное форматирование. Но лучше создать свою функцию:
|
||||
|
||||
```typescript
|
||||
formatTelegramMessage: (values, steps, subtotals, additional) => {
|
||||
// values — все поля формы (строки или числа)
|
||||
// steps — результаты шагов расчета
|
||||
// subtotals — результаты подитогов
|
||||
// additional — результаты дополнительных расчетов
|
||||
|
||||
let text = `📊 <b>Расчёт:</b>\n\n`;
|
||||
text += `Итого: ${subtotals.total.toFixed(1)} ₽\n`;
|
||||
|
||||
return text;
|
||||
}
|
||||
```
|
||||
|
||||
## Проверка работы
|
||||
|
||||
1. Убедитесь, что калькулятор появился в меню на главной странице
|
||||
2. Заполните форму и проверьте расчеты
|
||||
3. Отправьте результат в Telegram и убедитесь, что сообщение корректно форматировано
|
||||
|
||||
## Советы
|
||||
|
||||
- Используйте функцию `round()` для округления результатов (обычно до 1 знака после запятой)
|
||||
- Для сложных расчетов выносите логику в `calc.ts`
|
||||
- Группируйте связанные поля визуально, используя `gridCols`
|
||||
- Всегда указывайте `formulaDescription` для документации
|
||||
- Тестируйте с нулевыми и пустыми значениями
|
||||
|
||||
## Примеры
|
||||
|
||||
Полные примеры калькуляторов можно найти в:
|
||||
|
||||
**Калькулятор мыла:**
|
||||
- `frontend/calculators/soap/config.ts`
|
||||
- `frontend/calculators/soap/calc.ts`
|
||||
|
||||
**Калькулятор свечей (пример простого калькулятора):**
|
||||
- `frontend/calculators/candles/config.ts`
|
||||
- `frontend/calculators/candles/calc.ts`
|
||||
|
||||
Оба примера демонстрируют использование группировки полей, шагов расчета, подитогов и дополнительных расчетов.
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
// lib/calc.ts
|
||||
|
||||
type Input = {
|
||||
weight: number;
|
||||
basePrice: number;
|
||||
aromaPrice: number;
|
||||
aromaWeight: number;
|
||||
pigmentPrice: number;
|
||||
pigmentWeight: number;
|
||||
moldPrice: number;
|
||||
packaging: {
|
||||
box: number;
|
||||
filler: number;
|
||||
ribbon: number;
|
||||
label: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function calculateTotal(data: Input) {
|
||||
const {
|
||||
weight,
|
||||
basePrice,
|
||||
aromaPrice,
|
||||
aromaWeight,
|
||||
pigmentPrice,
|
||||
pigmentWeight,
|
||||
moldPrice,
|
||||
packaging,
|
||||
} = data;
|
||||
|
||||
const base = (weight / 1000) * basePrice;
|
||||
const aroma = ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||
const pigment = ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||
const mold = moldPrice / 100;
|
||||
const packagingCost = packaging.box + packaging.filler + packaging.ribbon + packaging.label;
|
||||
const subtotal = base + aroma + pigment + mold + packagingCost;
|
||||
const operational = subtotal * 0.05;
|
||||
const total = subtotal + operational;
|
||||
|
||||
return {
|
||||
base: round(base),
|
||||
aroma: round(aroma),
|
||||
pigment: round(pigment),
|
||||
mold: round(mold),
|
||||
packaging: round(packagingCost),
|
||||
operational: round(operational),
|
||||
total: round(total),
|
||||
};
|
||||
}
|
||||
|
||||
function round(val: number) {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
46
frontend/lib/calculator-registry.ts
Normal file
46
frontend/lib/calculator-registry.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// Система регистрации и управления калькуляторами
|
||||
import { CalculatorConfig } from './calculator-types';
|
||||
import { soapCalculatorConfig } from '@/calculators/soap/config';
|
||||
import { candlesCalculatorConfig } from '@/calculators/candles/config';
|
||||
|
||||
// Реестр всех доступных калькуляторов
|
||||
const calculators: Map<string, CalculatorConfig> = new Map();
|
||||
|
||||
// Регистрация калькуляторов
|
||||
export function registerCalculator(config: CalculatorConfig): void {
|
||||
if (calculators.has(config.id)) {
|
||||
console.warn(`Калькулятор с ID "${config.id}" уже зарегистрирован. Перезаписываем.`);
|
||||
}
|
||||
calculators.set(config.id, config);
|
||||
}
|
||||
|
||||
// Получить калькулятор по ID
|
||||
export function getCalculator(id: string): CalculatorConfig | undefined {
|
||||
return calculators.get(id);
|
||||
}
|
||||
|
||||
// Получить список всех калькуляторов
|
||||
export function getAllCalculators(): CalculatorConfig[] {
|
||||
return Array.from(calculators.values());
|
||||
}
|
||||
|
||||
// Получить список ID всех калькуляторов
|
||||
export function getAllCalculatorIds(): string[] {
|
||||
return Array.from(calculators.keys());
|
||||
}
|
||||
|
||||
// Инициализация: регистрация всех калькуляторов
|
||||
export function initializeCalculators(): void {
|
||||
// Регистрируем калькулятор мыла
|
||||
registerCalculator(soapCalculatorConfig);
|
||||
|
||||
// Регистрируем калькулятор свечей
|
||||
registerCalculator(candlesCalculatorConfig);
|
||||
|
||||
// Здесь будут регистрироваться другие калькуляторы
|
||||
// registerCalculator(otherCalculatorConfig);
|
||||
}
|
||||
|
||||
// Автоматическая инициализация при импорте модуля
|
||||
initializeCalculators();
|
||||
|
||||
91
frontend/lib/calculator-types.ts
Normal file
91
frontend/lib/calculator-types.ts
Normal file
@ -0,0 +1,91 @@
|
||||
// Система типов для модульной архитектуры калькуляторов
|
||||
|
||||
export type FieldType = 'text' | 'number' | 'file';
|
||||
|
||||
export interface FieldConfig {
|
||||
id: string;
|
||||
type: FieldType;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
gridCols?: 1 | 2; // Колонки в grid (1 или 2)
|
||||
required?: boolean;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
// Для полей типа file
|
||||
accept?: string;
|
||||
// ID шага расчета, который нужно показать после этой группы полей
|
||||
showStepAfter?: string;
|
||||
// Группа полей (поля с одинаковым groupName будут сгруппированы вместе)
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
export interface CalculationStep {
|
||||
id: string;
|
||||
name: string;
|
||||
// Формула может быть функцией или строкой для динамического вычисления
|
||||
formula: (values: Record<string, number>) => number;
|
||||
// Опционально: выражение в виде строки для документации
|
||||
formulaDescription?: string;
|
||||
}
|
||||
|
||||
export interface SubtotalConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
// Может быть ссылкой на шаг расчета или собственной формулой
|
||||
formula: (values: Record<string, number>, steps: Record<string, number>) => number;
|
||||
highlight?: boolean; // Для выделения итоговых значений
|
||||
formulaDescription?: string;
|
||||
}
|
||||
|
||||
export interface CalculatorConfig {
|
||||
id: string; // Уникальный идентификатор (например, 'soap', 'candles')
|
||||
name: string; // Отображаемое название
|
||||
description?: string; // Опциональное описание
|
||||
icon?: string; // Эмодзи или иконка для меню
|
||||
|
||||
// Группировка полей (опционально)
|
||||
fieldGroups?: {
|
||||
name: string;
|
||||
fields: string[]; // ID полей в этой группе
|
||||
}[];
|
||||
|
||||
// Конфигурация полей ввода
|
||||
fields: FieldConfig[];
|
||||
|
||||
// Шаги расчета
|
||||
calculationSteps: CalculationStep[];
|
||||
|
||||
// Подитоги
|
||||
subtotals: SubtotalConfig[];
|
||||
|
||||
// Дополнительные расчеты (например, цена за 100г, с наценкой и т.д.)
|
||||
additionalCalculations?: {
|
||||
id: string;
|
||||
name: string;
|
||||
formula: (values: Record<string, number>, steps: Record<string, number>, subtotals: Record<string, number>, additional?: Record<string, number>) => number;
|
||||
formulaDescription?: string;
|
||||
}[];
|
||||
|
||||
// Функция форматирования результата для Telegram
|
||||
formatTelegramMessage?: (
|
||||
values: Record<string, string | number>,
|
||||
steps: Record<string, number>,
|
||||
subtotals: Record<string, number>,
|
||||
additional: Record<string, number>
|
||||
) => string;
|
||||
}
|
||||
|
||||
// Тип для значений формы
|
||||
export type CalculatorValues = Record<string, string>;
|
||||
|
||||
// Тип для результатов расчета
|
||||
export interface CalculationResults {
|
||||
steps: Record<string, number>;
|
||||
subtotals: Record<string, number>;
|
||||
additional?: Record<string, number>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user