616 lines
26 KiB
JavaScript
616 lines
26 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { productSheetService } from '../../services/api';
|
|
import useFormatters from '../../hooks/useFormatters';
|
|
|
|
const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
|
|
const { t } = useTranslation();
|
|
const { currency } = useFormatters();
|
|
const isEditing = !!sheet;
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
sku: '',
|
|
description: '',
|
|
category: '',
|
|
currency: 'EUR',
|
|
business_setting_id: '',
|
|
is_active: true,
|
|
// Strategic pricing fields
|
|
competitor_price: '',
|
|
min_price: '',
|
|
max_price: '',
|
|
premium_multiplier: '1.00',
|
|
price_strategy: 'neutral',
|
|
psychological_rounding: false,
|
|
target_margin_percent: '',
|
|
});
|
|
|
|
const [items, setItems] = useState([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [showStrategicPricing, setShowStrategicPricing] = useState(false);
|
|
|
|
const itemTypes = [
|
|
{ value: 'product_cost', label: t('business.products.itemTypes.productCost') },
|
|
{ value: 'packaging', label: t('business.products.itemTypes.packaging') },
|
|
{ value: 'label', label: t('business.products.itemTypes.label') },
|
|
{ value: 'shipping', label: t('business.products.itemTypes.shipping') },
|
|
{ value: 'handling', label: t('business.products.itemTypes.handling') },
|
|
{ value: 'other', label: t('business.products.itemTypes.other') },
|
|
];
|
|
|
|
useEffect(() => {
|
|
if (sheet) {
|
|
setFormData({
|
|
name: sheet.name || '',
|
|
sku: sheet.sku || '',
|
|
description: sheet.description || '',
|
|
category: sheet.category || '',
|
|
currency: sheet.currency || 'EUR',
|
|
business_setting_id: sheet.business_setting_id || '',
|
|
is_active: sheet.is_active ?? true,
|
|
// Strategic pricing fields
|
|
competitor_price: sheet.competitor_price || '',
|
|
min_price: sheet.min_price || '',
|
|
max_price: sheet.max_price || '',
|
|
premium_multiplier: sheet.premium_multiplier || '1.00',
|
|
price_strategy: sheet.price_strategy || 'neutral',
|
|
psychological_rounding: sheet.psychological_rounding ?? false,
|
|
target_margin_percent: sheet.target_margin_percent || '',
|
|
});
|
|
setItems(sheet.items?.map(item => ({
|
|
id: item.id,
|
|
name: item.name,
|
|
type: item.type,
|
|
amount: item.amount,
|
|
quantity: item.quantity || 1,
|
|
unit: item.unit || '',
|
|
})) || []);
|
|
// Show strategic pricing if any field is set
|
|
if (sheet.competitor_price || sheet.min_price || sheet.max_price ||
|
|
sheet.target_margin_percent || sheet.price_strategy !== 'neutral') {
|
|
setShowStrategicPricing(true);
|
|
}
|
|
}
|
|
}, [sheet]);
|
|
|
|
const handleChange = (e) => {
|
|
const { name, value, type, checked } = e.target;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[name]: type === 'checkbox' ? checked : value,
|
|
}));
|
|
};
|
|
|
|
const handleItemChange = (index, field, value) => {
|
|
setItems(prev => prev.map((item, i) =>
|
|
i === index ? { ...item, [field]: value } : item
|
|
));
|
|
};
|
|
|
|
const addItem = () => {
|
|
setItems(prev => [...prev, {
|
|
name: '',
|
|
type: 'product_cost',
|
|
amount: '',
|
|
quantity: 1,
|
|
unit: '',
|
|
}]);
|
|
};
|
|
|
|
const removeItem = (index) => {
|
|
setItems(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
// Calcular CMV total
|
|
const calculateCmv = () => {
|
|
return items.reduce((sum, item) => {
|
|
const amount = parseFloat(item.amount) || 0;
|
|
const quantity = parseFloat(item.quantity) || 1;
|
|
return sum + (amount / quantity);
|
|
}, 0);
|
|
};
|
|
|
|
// Calcular preço de venda preview
|
|
const calculateSalePrice = () => {
|
|
if (!formData.business_setting_id) return null;
|
|
const setting = settings.find(s => s.id === parseInt(formData.business_setting_id));
|
|
if (!setting || !setting.markup_factor) return null;
|
|
return calculateCmv() * setting.markup_factor;
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
// Validar itens
|
|
const validItems = items.filter(item => item.name && item.amount);
|
|
|
|
try {
|
|
const dataToSend = {
|
|
...formData,
|
|
business_setting_id: formData.business_setting_id || null,
|
|
items: validItems.map(item => ({
|
|
name: item.name,
|
|
type: item.type,
|
|
amount: parseFloat(item.amount) || 0,
|
|
quantity: parseFloat(item.quantity) || 1,
|
|
unit: item.unit || null,
|
|
})),
|
|
};
|
|
|
|
let result;
|
|
if (isEditing) {
|
|
// Para edição, primeiro atualiza a ficha, depois gerencia itens
|
|
result = await productSheetService.update(sheet.id, {
|
|
name: formData.name,
|
|
sku: formData.sku,
|
|
description: formData.description,
|
|
category: formData.category,
|
|
currency: formData.currency,
|
|
business_setting_id: formData.business_setting_id || null,
|
|
is_active: formData.is_active,
|
|
// Strategic pricing fields
|
|
competitor_price: formData.competitor_price ? parseFloat(formData.competitor_price) : null,
|
|
min_price: formData.min_price ? parseFloat(formData.min_price) : null,
|
|
max_price: formData.max_price ? parseFloat(formData.max_price) : null,
|
|
premium_multiplier: parseFloat(formData.premium_multiplier) || 1.0,
|
|
price_strategy: formData.price_strategy,
|
|
psychological_rounding: formData.psychological_rounding,
|
|
target_margin_percent: formData.target_margin_percent ? parseFloat(formData.target_margin_percent) : null,
|
|
});
|
|
|
|
// Remover itens que não existem mais
|
|
const existingIds = items.filter(i => i.id).map(i => i.id);
|
|
for (const oldItem of sheet.items || []) {
|
|
if (!existingIds.includes(oldItem.id)) {
|
|
await productSheetService.removeItem(sheet.id, oldItem.id);
|
|
}
|
|
}
|
|
|
|
// Atualizar ou criar itens
|
|
for (const item of validItems) {
|
|
if (item.id) {
|
|
await productSheetService.updateItem(sheet.id, item.id, {
|
|
name: item.name,
|
|
type: item.type,
|
|
amount: parseFloat(item.amount) || 0,
|
|
quantity: parseFloat(item.quantity) || 1,
|
|
unit: item.unit || null,
|
|
});
|
|
} else {
|
|
await productSheetService.addItem(sheet.id, {
|
|
name: item.name,
|
|
type: item.type,
|
|
amount: parseFloat(item.amount) || 0,
|
|
quantity: parseFloat(item.quantity) || 1,
|
|
unit: item.unit || null,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Recalcular preço
|
|
if (formData.business_setting_id) {
|
|
result = await productSheetService.recalculatePrice(sheet.id, formData.business_setting_id);
|
|
} else {
|
|
result = await productSheetService.getById(sheet.id);
|
|
}
|
|
} else {
|
|
result = await productSheetService.create(dataToSend);
|
|
}
|
|
|
|
onSave(result);
|
|
} catch (err) {
|
|
setError(err.response?.data?.message || err.response?.data?.errors || t('common.error'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const currencies = ['EUR', 'USD', 'BRL', 'GBP'];
|
|
const cmvTotal = calculateCmv();
|
|
const salePrice = calculateSalePrice();
|
|
|
|
return (
|
|
<div className="modal show d-block" style={{ background: 'rgba(0,0,0,0.8)' }}>
|
|
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" style={{ maxHeight: '90vh' }}>
|
|
<div className="modal-content" style={{ background: '#1e293b', border: 'none', maxHeight: '90vh' }}>
|
|
<div className="modal-header border-0">
|
|
<h5 className="modal-title text-white">
|
|
<i className="bi bi-box-seam me-2"></i>
|
|
{isEditing ? t('business.products.edit') : t('business.products.add')}
|
|
</h5>
|
|
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="modal-body" style={{ maxHeight: 'calc(90vh - 130px)', overflowY: 'auto' }}>
|
|
{error && (
|
|
<div className="alert alert-danger">{typeof error === 'string' ? error : JSON.stringify(error)}</div>
|
|
)}
|
|
|
|
<div className="row g-3">
|
|
{/* Info básica */}
|
|
<div className="col-md-8">
|
|
<label className="form-label text-slate-400">{t('business.products.name')}</label>
|
|
<input
|
|
type="text"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="name"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
placeholder={t('business.products.namePlaceholder')}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">SKU</label>
|
|
<input
|
|
type="text"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="sku"
|
|
value={formData.sku}
|
|
onChange={handleChange}
|
|
placeholder={t('business.products.skuPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">{t('common.currency')}</label>
|
|
<select
|
|
className="form-select bg-dark text-white border-secondary"
|
|
name="currency"
|
|
value={formData.currency}
|
|
onChange={handleChange}
|
|
required
|
|
>
|
|
{currencies.map(c => (
|
|
<option key={c} value={c}>{c}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">{t('business.products.category')}</label>
|
|
<input
|
|
type="text"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="category"
|
|
value={formData.category}
|
|
onChange={handleChange}
|
|
placeholder={t('business.products.categoryPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">{t('business.products.businessSetting')}</label>
|
|
<select
|
|
className="form-select bg-dark text-white border-secondary"
|
|
name="business_setting_id"
|
|
value={formData.business_setting_id}
|
|
onChange={handleChange}
|
|
>
|
|
<option value="">{t('business.products.noSetting')}</option>
|
|
{settings.filter(s => s.is_active).map(s => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name} (Markup: {parseFloat(s.markup_factor || 0).toFixed(2)})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="col-12">
|
|
<label className="form-label text-slate-400">{t('common.description')}</label>
|
|
<textarea
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="description"
|
|
value={formData.description}
|
|
onChange={handleChange}
|
|
rows="2"
|
|
/>
|
|
</div>
|
|
|
|
{/* Strategic Pricing Toggle */}
|
|
<div className="col-12">
|
|
<hr className="border-secondary my-2" />
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-outline-info w-100"
|
|
onClick={() => setShowStrategicPricing(!showStrategicPricing)}
|
|
>
|
|
<i className={`bi bi-chevron-${showStrategicPricing ? 'up' : 'down'} me-2`}></i>
|
|
{t('business.products.strategicPricing')}
|
|
<i className="bi bi-graph-up ms-2"></i>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Strategic Pricing Fields */}
|
|
{showStrategicPricing && (
|
|
<>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">
|
|
<i className="bi bi-building me-1"></i>
|
|
{t('business.products.competitorPrice')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="competitor_price"
|
|
value={formData.competitor_price}
|
|
onChange={handleChange}
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">
|
|
<i className="bi bi-arrow-down me-1"></i>
|
|
{t('business.products.minPrice')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="min_price"
|
|
value={formData.min_price}
|
|
onChange={handleChange}
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">
|
|
<i className="bi bi-arrow-up me-1"></i>
|
|
{t('business.products.maxPrice')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="max_price"
|
|
value={formData.max_price}
|
|
onChange={handleChange}
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">
|
|
<i className="bi bi-star me-1"></i>
|
|
{t('business.products.premiumMultiplier')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0.1"
|
|
max="5"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="premium_multiplier"
|
|
value={formData.premium_multiplier}
|
|
onChange={handleChange}
|
|
/>
|
|
<small className="text-slate-500">1.0 = {t('business.products.neutral')}</small>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">
|
|
<i className="bi bi-bullseye me-1"></i>
|
|
{t('business.products.priceStrategy')}
|
|
</label>
|
|
<select
|
|
className="form-select bg-dark text-white border-secondary"
|
|
name="price_strategy"
|
|
value={formData.price_strategy}
|
|
onChange={handleChange}
|
|
>
|
|
<option value="aggressive">{t('business.products.strategyAggressive')}</option>
|
|
<option value="neutral">{t('business.products.strategyNeutral')}</option>
|
|
<option value="premium">{t('business.products.strategyPremium')}</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400">
|
|
<i className="bi bi-percent me-1"></i>
|
|
{t('business.products.targetMargin')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="99"
|
|
className="form-control bg-dark text-white border-secondary"
|
|
name="target_margin_percent"
|
|
value={formData.target_margin_percent}
|
|
onChange={handleChange}
|
|
placeholder={t('business.products.useGlobal')}
|
|
/>
|
|
</div>
|
|
<div className="col-12">
|
|
<div className="form-check">
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
id="psychological_rounding"
|
|
name="psychological_rounding"
|
|
checked={formData.psychological_rounding}
|
|
onChange={handleChange}
|
|
/>
|
|
<label className="form-check-label text-slate-400" htmlFor="psychological_rounding">
|
|
<i className="bi bi-magic me-1"></i>
|
|
{t('business.products.psychologicalRounding')}
|
|
<small className="text-slate-500 ms-2">({t('business.products.psychologicalExample')})</small>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Componentes de Custo (CMV) */}
|
|
<div className="col-12">
|
|
<hr className="border-secondary my-2" />
|
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
|
<h6 className="text-slate-400 small mb-0">
|
|
<i className="bi bi-list-check me-2"></i>
|
|
{t('business.products.costComponents')}
|
|
</h6>
|
|
<button type="button" className="btn btn-sm btn-outline-primary" onClick={addItem}>
|
|
<i className="bi bi-plus-lg me-1"></i>
|
|
{t('business.products.addComponent')}
|
|
</button>
|
|
</div>
|
|
|
|
{items.length === 0 ? (
|
|
<div className="text-center py-4 text-slate-500">
|
|
<i className="bi bi-inbox fs-3 mb-2 d-block"></i>
|
|
{t('business.products.noComponents')}
|
|
</div>
|
|
) : (
|
|
<div className="table-responsive">
|
|
<table className="table table-dark table-sm mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th className="text-slate-400" style={{ width: '30%' }}>{t('business.products.componentName')}</th>
|
|
<th className="text-slate-400" style={{ width: '20%' }}>{t('common.type')}</th>
|
|
<th className="text-slate-400 text-end" style={{ width: '20%' }}>{t('business.products.amount')}</th>
|
|
<th className="text-slate-400 text-end" style={{ width: '15%' }}>{t('business.products.quantity')}</th>
|
|
<th className="text-slate-400 text-end" style={{ width: '15%' }}>{t('business.products.unitCost')}</th>
|
|
<th style={{ width: '40px' }}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item, index) => {
|
|
const unitCost = (parseFloat(item.amount) || 0) / (parseFloat(item.quantity) || 1);
|
|
return (
|
|
<tr key={index}>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
className="form-control form-control-sm bg-dark text-white border-secondary"
|
|
value={item.name}
|
|
onChange={(e) => handleItemChange(index, 'name', e.target.value)}
|
|
placeholder={t('business.products.componentNamePlaceholder')}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<select
|
|
className="form-select form-select-sm bg-dark text-white border-secondary"
|
|
value={item.type}
|
|
onChange={(e) => handleItemChange(index, 'type', e.target.value)}
|
|
>
|
|
{itemTypes.map(type => (
|
|
<option key={type.value} value={type.value}>{type.label}</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="form-control form-control-sm bg-dark text-white border-secondary text-end"
|
|
value={item.amount}
|
|
onChange={(e) => handleItemChange(index, 'amount', e.target.value)}
|
|
placeholder="0.00"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="form-control form-control-sm bg-dark text-white border-secondary text-end"
|
|
value={item.quantity}
|
|
onChange={(e) => handleItemChange(index, 'quantity', e.target.value)}
|
|
placeholder="1"
|
|
/>
|
|
</td>
|
|
<td className="text-end text-white align-middle">
|
|
{currency(unitCost, formData.currency)}
|
|
</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-outline-danger border-0"
|
|
onClick={() => removeItem(index)}
|
|
>
|
|
<i className="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Preview CMV e Preço de Venda */}
|
|
<div className="col-12">
|
|
<div className="row g-3 mt-2">
|
|
<div className="col-md-6">
|
|
<div className="p-3 rounded text-center" style={{ background: 'rgba(239, 68, 68, 0.1)' }}>
|
|
<small className="text-slate-500 d-block mb-1">{t('business.products.cmvTotalLabel')}</small>
|
|
<h4 className="text-danger mb-0">{currency(cmvTotal, formData.currency)}</h4>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<div className="p-3 rounded text-center" style={{ background: salePrice ? 'rgba(16, 185, 129, 0.1)' : 'rgba(255,255,255,0.05)' }}>
|
|
<small className="text-slate-500 d-block mb-1">{t('business.products.salePrice')}</small>
|
|
<h4 className={salePrice ? 'text-success mb-0' : 'text-slate-500 mb-0'}>
|
|
{salePrice ? currency(salePrice, formData.currency) : '-'}
|
|
</h4>
|
|
{!formData.business_setting_id && (
|
|
<small className="text-slate-500">{t('business.products.selectSettingForPrice')}</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
{isEditing && (
|
|
<div className="col-12">
|
|
<div className="form-check">
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
id="is_active"
|
|
name="is_active"
|
|
checked={formData.is_active}
|
|
onChange={handleChange}
|
|
/>
|
|
<label className="form-check-label text-slate-400" htmlFor="is_active">
|
|
{t('business.products.isActive')}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="modal-footer border-0">
|
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
|
{saving ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
|
{t('common.saving')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<i className="bi bi-check-lg me-2"></i>
|
|
{t('common.save')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProductSheetModal;
|