DoSoapCalc/frontend/components/DynamicCalculator.tsx
dosai 02ad29e64b feat: Добавлена навигация между калькуляторами и исправлена ошибка API
- Добавлен компонент CalculatorNav для переключения между калькуляторами
- Навигация сохраняет chat_id при переходах
- Исправлен endpoint API (используется правильный путь с типом калькулятора)
- Пересобран frontend для применения изменений
2025-11-01 20:18:57 +03:00

334 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/DynamicCalculator.tsx
// Универсальный компонент для генерации калькуляторов из схемы
'use client';
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import Image from 'next/image';
import { getCalculator } from '@/lib/calculators';
import { submitCalculator } from '@/lib/api';
import FormField from './FormField';
import CalculatorNav from './CalculatorNav';
import type { Calculator } from '@/types/calculator';
interface DynamicCalculatorProps {
calculatorType: string;
}
// Форматирование меток результатов
function formatResultLabel(key: string): string {
const labels: Record<string, string> = {
base: 'Себестоимость основы',
aroma: 'Себестоимость отдушки',
pigment: 'Себестоимость пигмента',
mold: 'Себестоимость формы',
packaging: 'Стоимость упаковки',
operational: 'Операционные расходы (5%)',
wax: 'Себестоимость воска',
wick: 'Себестоимость фитиля',
};
return labels[key] || key;
}
export default function DynamicCalculator({ calculatorType }: DynamicCalculatorProps) {
const calculator = getCalculator(calculatorType);
if (!calculator) {
return (
<div className="max-w-xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg">
<h1 className="text-2xl font-bold text-red-400">
Калькулятор &quot;{calculatorType}&quot; не найден
</h1>
</div>
);
}
const [formData, setFormData] = useState<Record<string, string>>({});
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [chatId, setChatId] = useState<string | null>(null);
// Инициализация формы с пустыми значениями
useEffect(() => {
const initialData: Record<string, string> = {};
calculator.fieldSchema.forEach((field) => {
initialData[field.name] = '';
});
setFormData(initialData);
}, [calculator]);
// Получаем chat_id из URL
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const id = params.get('chat_id');
if (id) {
setChatId(id);
}
}, []);
// Преобразование строки в число
const toNum = (str: string): number => {
const n = parseFloat(str.replace(',', '.'));
return isNaN(n) ? 0 : n;
};
// Обновление значения поля
const updateField = (fieldName: string, value: string) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
};
// Подготовка данных для расчёта
const prepareCalculationData = (): Record<string, any> => {
const data: Record<string, any> = {};
for (const field of calculator.fieldSchema) {
if (field.type === 'number') {
data[field.name] = toNum(formData[field.name] || '0');
} else {
data[field.name] = formData[field.name] || '';
}
}
// Специфичная обработка для мыла (упаковка)
if (calculator.id === 'soap') {
data.packaging = {
box: toNum(formData.box || '0'),
filler: toNum(formData.filler || '0'),
ribbon: toNum(formData.ribbon || '0'),
label: toNum(formData.labelValue || '0'),
};
}
return data;
};
// Выполнение расчёта
const calculationData = prepareCalculationData();
const result = calculator.calculate(calculationData);
// Вычисление финальной цены
const markup = toNum(formData.markup || '0');
const finalPrice = result.total * (1 + markup / 100);
const weight = toNum(formData.weight || '0');
const pricePer100g = weight > 0 ? (finalPrice / weight) * 100 : 0;
// Группировка полей по группам
const groupedFields = calculator.fieldSchema.reduce((acc, field) => {
const group = field.group || 'general';
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(field);
return acc;
}, {} as Record<string, typeof calculator.fieldSchema>);
// Обработка изменения фото
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 requiredFields = calculator.getRequiredFields();
for (const fieldName of requiredFields) {
if (!formData[fieldName] || formData[fieldName].trim() === '') {
const field = calculator.fieldSchema.find((f) => f.name === fieldName);
alert(`❗ Поле "${field?.label || fieldName}" обязательно для заполнения`);
return;
}
}
// Подготовка данных для отправки
const submitData: Record<string, string | number> = {};
for (const field of calculator.fieldSchema) {
if (field.type === 'number') {
const value = toNum(formData[field.name] || '0');
submitData[field.name] = value;
} else {
submitData[field.name] = formData[field.name] || '';
}
}
// Специфичная обработка для мыла (упаковка отправляется как отдельные поля)
// Для других калькуляторов поля отправляются как есть
if (calculator.id === 'soap') {
submitData.box = toNum(formData.box || '0');
submitData.filler = toNum(formData.filler || '0');
submitData.ribbon = toNum(formData.ribbon || '0');
submitData.labelValue = toNum(formData.labelValue || '0');
}
// Добавляем результаты расчёта
submitData.totalCost = result.total;
submitData.finalPrice = finalPrice;
submitData.pricePer100g = pricePer100g;
// Отправка на backend
const response = await submitCalculator(
calculatorType,
chatId,
submitData,
photoFile
);
if (response.success) {
alert('✅ Расчёт успешно отправлен в Telegram!');
// Очистка формы
const initialData: Record<string, string> = {};
calculator.fieldSchema.forEach((field) => {
initialData[field.name] = '';
});
setFormData(initialData);
setPhotoFile(null);
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
alert(`❌ Ошибка: ${response.error || 'Неизвестная ошибка'}`);
}
};
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>
{/* Навигация между калькуляторами */}
<CalculatorNav />
{/* Предупреждение о chat_id */}
{chatId === null && (
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
Не найден chat_id. Откройте калькулятор через Telegram-бота.
</div>
)}
{/* Заголовок калькулятора */}
<h1 className="text-2xl font-bold text-center">
Калькулятор: {calculator.name}
</h1>
{/* Основные поля (без группы) */}
{groupedFields.general && (
<div className="space-y-4">
{groupedFields.general.map((field) => (
<FormField
key={field.id}
field={field}
value={formData[field.name] || ''}
onChange={(value) => updateField(field.name, value)}
/>
))}
</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"
/>
)}
{/* Группированные поля (например, упаковка) */}
{Object.entries(groupedFields)
.filter(([group]) => group !== 'general')
.map(([group, fields]) => (
<div key={group} className="space-y-4">
<h2 className="text-xl font-semibold text-gray-300 capitalize">
{group === 'packaging' ? 'Упаковка' : group}
</h2>
<div className="grid grid-cols-2 gap-4">
{fields.map((field) => (
<FormField
key={field.id}
field={field}
value={formData[field.name] || ''}
onChange={(value) => updateField(field.name, value)}
/>
))}
</div>
</div>
))}
{/* Блоки с результатами расчёта */}
<div className="space-y-2">
{Object.entries(result)
.filter(([key]) => key !== 'total')
.map(([key, value]) => (
<div key={key} className="p-2 bg-gray-700 text-gray-200 rounded">
{formatResultLabel(key)}: {value.toFixed(1)}
</div>
))}
<div className="p-2 bg-gray-600 font-semibold text-gray-200 rounded">
Итого себестоимость: {result.total.toFixed(1)}
</div>
<div className="p-2 bg-gray-700 text-gray-200 rounded">
Итоговая цена с наценкой: {finalPrice.toFixed(1)}
</div>
{weight > 0 && (
<div className="p-2 bg-gray-700 text-gray-200 rounded">
Цена за 100 г: {pricePer100g.toFixed(1)}
</div>
)}
</div>
{/* Кнопка отправки */}
<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>
);
}