DoSoapCalc/frontend/components/CalculatorEngine.tsx

551 lines
16 KiB
TypeScript
Raw Permalink 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.

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