feat: Wine House product variants - 4 portion sizes (bottle/glass/half/tasting) v1.31.0

This commit is contained in:
CnxiFly Dev 2025-12-14 11:56:57 +01:00
parent 1be3355a00
commit c31195b24f
11 changed files with 763 additions and 5 deletions

View File

@ -5,6 +5,39 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.31.0] - 2025-12-14
### Added
- **Variantes de Producto** - Sistema de porciones para Wine House
- Permite vender mismo producto en diferentes porciones (Botella, Copa, Media Copa, Degustación)
- Cálculo automático: CMV proporcional, markup, precio de venta por porción
- Nueva tabla `product_variants` con campos: portion_ratio, quantity_per_unit, volume_ml
- Modelo `ProductVariant` con métodos: calculateCmvPortion(), calculateSalePrice(), recalculate()
- Relación HasMany en ProductSheet para variantes
- **Vinoteca Madrid** - Nuevo negocio de ejemplo en seeder
- 10 vinos españoles: Rioja, Ribera del Duero, Rías Baixas, Rueda, Cava, Porto
- Cada vino con 4 variantes automáticas:
- Garrafa (750ml) - 1 por botella
- Taça (100ml) - 7.5 por botella
- Meia Taça (50ml) - 15 por botella
- Degustação (25ml) - 30 por botella
- Markup calculado automáticamente según configuración del negocio
- **UI Variantes en ProductSheetModal**
- Toggle para mostrar/ocultar sección de variantes
- Inputs de volume base (ml) y porción estándar (ml)
- Botón "Generar para Vino" que crea 4 variantes automáticas
- Tabla con columnas: Nombre, Porción, Cantidad/Unidad, CMV, Markup, PVP
- Vista previa de cálculos en tiempo real
- **Traducciones i18n para Variantes**
- `business.variants.*`: title, description, add, noVariants
- Nombres de porciones: bottle, glass, halfGlass, tasting
- Labels: baseVolume, portionSize, autoGenerate
- Soporte: PT-BR, ES, EN
## [1.30.0] - 2025-12-14
### Added

View File

@ -1 +1 @@
1.30.0
1.31.0

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Models\ProductSheet;
use App\Models\ProductSheetItem;
use App\Models\ProductVariant;
use App\Models\BusinessSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -21,7 +22,7 @@ public function index(Request $request): JsonResponse
$userId = $request->user()->id;
$query = ProductSheet::ofUser($userId)
->with(['items', 'businessSetting:id,name,currency,markup_factor']);
->with(['items', 'variants', 'businessSetting:id,name,currency,markup_factor']);
// Filtro por categoria
if ($request->has('category')) {
@ -58,6 +59,8 @@ public function store(Request $request): JsonResponse
'sku' => 'nullable|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'base_volume_ml' => 'nullable|integer|min:1',
'standard_portion_ml' => 'nullable|integer|min:1',
'currency' => 'required|string|size:3',
'business_setting_id' => 'nullable|exists:business_settings,id',
'items' => 'nullable|array',
@ -66,6 +69,13 @@ public function store(Request $request): JsonResponse
'items.*.amount' => 'required|numeric|min:0',
'items.*.quantity' => 'nullable|numeric|min:0',
'items.*.unit' => 'nullable|string|max:20',
'variants' => 'nullable|array',
'variants.*.name' => 'required|string|max:255',
'variants.*.portion_ratio' => 'required|numeric|min:0|max:1',
'variants.*.quantity_per_unit' => 'nullable|numeric|min:0',
'variants.*.volume_ml' => 'nullable|integer|min:1',
'variants.*.markup' => 'nullable|numeric|min:1',
'variants.*.is_default' => 'nullable|boolean',
]);
if ($validator->fails()) {
@ -93,6 +103,8 @@ public function store(Request $request): JsonResponse
'sku' => $request->sku,
'description' => $request->description,
'category' => $request->category,
'base_volume_ml' => $request->base_volume_ml,
'standard_portion_ml' => $request->standard_portion_ml,
'currency' => $request->currency,
]);
@ -119,10 +131,31 @@ public function store(Request $request): JsonResponse
$sheet->calculateSalePrice();
}
// Criar as variantes
if ($request->has('variants') && is_array($request->variants)) {
$markup = $sheet->markup_used ?? $sheet->businessSetting?->markup_factor ?? 2.5;
foreach ($request->variants as $index => $variantData) {
$variant = new ProductVariant([
'product_sheet_id' => $sheet->id,
'name' => $variantData['name'],
'portion_ratio' => $variantData['portion_ratio'],
'quantity_per_unit' => $variantData['quantity_per_unit'] ?? null,
'volume_ml' => $variantData['volume_ml'] ?? null,
'markup' => $variantData['markup'] ?? $markup,
'is_default' => $variantData['is_default'] ?? ($index === 0),
'sort_order' => $index,
]);
$variant->productSheet = $sheet;
$variant->recalculate($variantData['markup'] ?? $markup);
$variant->save();
}
}
DB::commit();
// Recarregar com relacionamentos
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
$sheet->load(['items', 'variants', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'contribution_margin_percent' => $sheet->contribution_margin_percent,
@ -140,7 +173,7 @@ public function store(Request $request): JsonResponse
public function show(Request $request, $id): JsonResponse
{
$sheet = ProductSheet::ofUser($request->user()->id)
->with(['items', 'businessSetting'])
->with(['items', 'variants', 'businessSetting'])
->findOrFail($id);
return response()->json(array_merge($sheet->toArray(), [
@ -160,6 +193,8 @@ public function update(Request $request, $id): JsonResponse
'sku' => 'nullable|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'base_volume_ml' => 'nullable|integer|min:1',
'standard_portion_ml' => 'nullable|integer|min:1',
'currency' => 'sometimes|string|size:3',
'business_setting_id' => 'nullable|exists:business_settings,id',
'is_active' => 'sometimes|boolean',
@ -171,6 +206,15 @@ public function update(Request $request, $id): JsonResponse
'price_strategy' => 'nullable|in:aggressive,neutral,premium',
'psychological_rounding' => 'nullable|boolean',
'target_margin_percent' => 'nullable|numeric|min:0|max:99',
// Variants
'variants' => 'nullable|array',
'variants.*.id' => 'nullable|integer',
'variants.*.name' => 'required|string|max:255',
'variants.*.portion_ratio' => 'required|numeric|min:0|max:1',
'variants.*.quantity_per_unit' => 'nullable|numeric|min:0',
'variants.*.volume_ml' => 'nullable|integer|min:1',
'variants.*.markup' => 'nullable|numeric|min:1',
'variants.*.is_default' => 'nullable|boolean',
]);
if ($validator->fails()) {
@ -195,7 +239,56 @@ public function update(Request $request, $id): JsonResponse
// Calcular preço final com estratégias
$sheet->calculateFinalPrice();
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
// Atualizar variantes
if ($request->has('variants') && is_array($request->variants)) {
$markup = $sheet->markup_used ?? $sheet->businessSetting?->markup_factor ?? 2.5;
$existingIds = [];
foreach ($request->variants as $index => $variantData) {
if (!empty($variantData['id'])) {
// Atualizar variante existente
$variant = ProductVariant::where('product_sheet_id', $sheet->id)
->find($variantData['id']);
if ($variant) {
$variant->update([
'name' => $variantData['name'],
'portion_ratio' => $variantData['portion_ratio'],
'quantity_per_unit' => $variantData['quantity_per_unit'] ?? null,
'volume_ml' => $variantData['volume_ml'] ?? null,
'markup' => $variantData['markup'] ?? $markup,
'is_default' => $variantData['is_default'] ?? false,
'sort_order' => $index,
]);
$variant->recalculate($variantData['markup'] ?? $markup);
$variant->save();
$existingIds[] = $variant->id;
}
} else {
// Criar nova variante
$variant = new ProductVariant([
'product_sheet_id' => $sheet->id,
'name' => $variantData['name'],
'portion_ratio' => $variantData['portion_ratio'],
'quantity_per_unit' => $variantData['quantity_per_unit'] ?? null,
'volume_ml' => $variantData['volume_ml'] ?? null,
'markup' => $variantData['markup'] ?? $markup,
'is_default' => $variantData['is_default'] ?? false,
'sort_order' => $index,
]);
$variant->productSheet = $sheet;
$variant->recalculate($variantData['markup'] ?? $markup);
$variant->save();
$existingIds[] = $variant->id;
}
}
// Remover variantes que não estão na lista
ProductVariant::where('product_sheet_id', $sheet->id)
->whereNotIn('id', $existingIds)
->delete();
}
$sheet->load(['items', 'variants', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'contribution_margin_percent' => $sheet->contribution_margin_percent,

View File

@ -18,6 +18,8 @@ class ProductSheet extends Model
'sku',
'description',
'category',
'base_volume_ml',
'standard_portion_ml',
'currency',
'cmv_total',
'sale_price',
@ -82,6 +84,14 @@ public function items(): HasMany
return $this->hasMany(ProductSheetItem::class)->orderBy('sort_order');
}
/**
* Variantes de venda do produto (ex: Garrafa, Taça, Meia Taça, Degustar)
*/
public function variants(): HasMany
{
return $this->hasMany(ProductVariant::class)->orderBy('sort_order');
}
/**
* Recalcula o CMV total baseado nos itens
*

View File

@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductVariant extends Model
{
protected $fillable = [
'product_sheet_id',
'name',
'portion_ratio',
'quantity_per_unit',
'volume_ml',
'cmv_portion',
'markup',
'sale_price',
'contribution_margin',
'margin_percentage',
'is_default',
'sort_order',
];
protected $casts = [
'portion_ratio' => 'decimal:4',
'quantity_per_unit' => 'decimal:2',
'cmv_portion' => 'decimal:2',
'markup' => 'decimal:4',
'sale_price' => 'decimal:2',
'contribution_margin' => 'decimal:2',
'margin_percentage' => 'decimal:2',
'is_default' => 'boolean',
];
/**
* Relacionamento com ProductSheet
*/
public function productSheet(): BelongsTo
{
return $this->belongsTo(ProductSheet::class);
}
/**
* Calcula o CMV proporcional baseado no CMV total do produto
*/
public function calculateCmvPortion(): float
{
$productCmv = $this->productSheet->cmv_total ?? 0;
return $productCmv * $this->portion_ratio;
}
/**
* Calcula o preço de venda baseado no markup
*/
public function calculateSalePrice(?float $markup = null): float
{
$useMarkup = $markup ?? $this->markup ?? $this->productSheet->markup_used ?? 2.5;
return $this->cmv_portion * $useMarkup;
}
/**
* Recalcula todos os valores
*/
public function recalculate(?float $markup = null): void
{
$this->cmv_portion = $this->calculateCmvPortion();
$this->sale_price = $this->calculateSalePrice($markup);
$this->contribution_margin = $this->sale_price - $this->cmv_portion;
if ($this->sale_price > 0) {
$this->margin_percentage = ($this->contribution_margin / $this->sale_price) * 100;
}
}
}

View File

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Variantes de Produto - Permite múltiplas opções de venda por produto
* Ex: Garrafa de vinho pode ser vendida como Garrafa, Taça, Meia Taça, Degustar
*/
public function up(): void
{
Schema::create('product_variants', function (Blueprint $table) {
$table->id();
$table->foreignId('product_sheet_id')->constrained()->onDelete('cascade');
// Nome da variante (ex: "Garrafa", "Taça", "Meia Taça", "Degustar")
$table->string('name');
// Porção relativa ao produto base (ex: 1.0 = garrafa inteira, 0.1333 = taça de 100ml de 750ml)
$table->decimal('portion_ratio', 8, 4)->default(1);
// Quantidade que pode ser obtida do produto base (ex: 7.5 taças de uma garrafa)
$table->decimal('quantity_per_unit', 8, 2)->nullable();
// Volume em ml (para bebidas)
$table->integer('volume_ml')->nullable();
// CMV proporcional (calculado automaticamente: CMV_total × portion_ratio)
$table->decimal('cmv_portion', 15, 2)->default(0);
// Markup específico para esta variante (pode ser diferente do markup geral)
$table->decimal('markup', 8, 4)->nullable();
// Preço de venda calculado
$table->decimal('sale_price', 15, 2)->nullable();
// Margem de contribuição
$table->decimal('contribution_margin', 15, 2)->nullable();
$table->decimal('margin_percentage', 8, 2)->nullable();
// Se é a variante padrão
$table->boolean('is_default')->default(false);
// Ordem de exibição
$table->integer('sort_order')->default(0);
$table->timestamps();
// Índices
$table->index(['product_sheet_id', 'sort_order']);
$table->index(['product_sheet_id', 'is_default']);
});
// Adicionar campo de volume base ao product_sheets para cálculos de porção
Schema::table('product_sheets', function (Blueprint $table) {
$table->integer('base_volume_ml')->nullable()->after('category');
$table->integer('standard_portion_ml')->nullable()->after('base_volume_ml');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('product_variants');
Schema::table('product_sheets', function (Blueprint $table) {
$table->dropColumn(['base_volume_ml', 'standard_portion_ml']);
});
}
};

View File

@ -6,6 +6,7 @@
use App\Models\BusinessSetting;
use App\Models\ProductSheet;
use App\Models\ProductSheetItem;
use App\Models\ProductVariant;
use App\Models\ServiceSheet;
use App\Models\ServiceSheetItem;
@ -200,10 +201,82 @@ public function run(): void
['name' => 'Templates Canva Pro', 'cost' => 2, 'quantity' => 1],
], 60); // 1 hora
// =====================================================================
// NEGÓCIO 4: WINE HOUSE MADRID (Vinhos com Variantes de Porção)
// =====================================================================
$wineHouse = BusinessSetting::create([
'user_id' => $userId,
'name' => 'Vinoteca Madrid',
'currency' => 'EUR',
'business_type' => 'products',
'employees_count' => 3,
'hours_per_week' => 48,
'working_days_per_week' => 6,
'working_days_per_month' => 26,
'productivity_rate' => 90,
'monthly_revenue' => 35000,
'fixed_expenses' => 7500,
'tax_rate' => 21,
'price_includes_tax' => true,
'vat_rate' => 21,
'sales_commission' => 0,
'card_fee' => 1.5,
'other_variable_costs' => 3,
'investment_rate' => 5,
'profit_margin' => 20,
'is_active' => true,
]);
$wineHouse->recalculateMarkup();
// Vinhos Tintos Espanhóis
$this->createWine($wineHouse, 'Marqués de Riscal Reserva', 'RIOJA-001', 'Tintos - Rioja', 22.50, [
['name' => 'Custo da garrafa', 'cost' => 22.50, 'quantity' => 1],
]);
$this->createWine($wineHouse, 'Pesquera Crianza', 'RIBERA-001', 'Tintos - Ribera del Duero', 18.00, [
['name' => 'Custo da garrafa', 'cost' => 18.00, 'quantity' => 1],
]);
$this->createWine($wineHouse, 'Protos Roble', 'RIBERA-002', 'Tintos - Ribera del Duero', 12.50, [
['name' => 'Custo da garrafa', 'cost' => 12.50, 'quantity' => 1],
]);
$this->createWine($wineHouse, 'Viña Ardanza Reserva', 'RIOJA-002', 'Tintos - Rioja', 28.00, [
['name' => 'Custo da garrafa', 'cost' => 28.00, 'quantity' => 1],
]);
$this->createWine($wineHouse, 'Pago de los Capellanes', 'RIBERA-003', 'Tintos - Ribera del Duero', 35.00, [
['name' => 'Custo da garrafa', 'cost' => 35.00, 'quantity' => 1],
]);
// Vinhos Brancos Espanhóis
$this->createWine($wineHouse, 'Albariño Martín Códax', 'RIAS-001', 'Brancos - Rías Baixas', 14.00, [
['name' => 'Custo da garrafa', 'cost' => 14.00, 'quantity' => 1],
]);
$this->createWine($wineHouse, 'Rueda Verdejo Oro de Castilla', 'RUEDA-001', 'Brancos - Rueda', 8.50, [
['name' => 'Custo da garrafa', 'cost' => 8.50, 'quantity' => 1],
]);
// Champagne & Espumantes
$this->createWine($wineHouse, 'Cava Codorníu Gran Plus Ultra', 'CAVA-001', 'Espumantes', 24.00, [
['name' => 'Custo da garrafa', 'cost' => 24.00, 'quantity' => 1],
]);
$this->createWine($wineHouse, 'Freixenet Carta Nevada', 'CAVA-002', 'Espumantes', 7.50, [
['name' => 'Custo da garrafa', 'cost' => 7.50, 'quantity' => 1],
]);
// Vinho do Porto
$this->createWine($wineHouse, 'Graham\'s 10 Year Tawny', 'PORTO-001', 'Vinhos do Porto', 32.00, [
['name' => 'Custo da garrafa', 'cost' => 32.00, 'quantity' => 1],
]);
$this->command->info('Business examples seeded successfully!');
$this->command->info('- TechStore (Products): ' . $ecommerce->productSheets()->count() . ' products');
$this->command->info('- DevPro (Services): ' . $consultoria->serviceSheets()->count() . ' services');
$this->command->info('- Print & Design (Both): ' . $grafica->productSheets()->count() . ' products, ' . $grafica->serviceSheets()->count() . ' services');
$this->command->info('- Vinoteca Madrid (Wine House): ' . $wineHouse->productSheets()->count() . ' wines with variants');
}
/**
@ -285,4 +358,97 @@ private function createService(BusinessSetting $setting, string $name, string $c
return $service;
}
/**
* Create a wine product with 4 variants: Bottle, Glass, Half Glass, Tasting
* Standard volumes: Bottle=750ml, Glass=100ml, Half=50ml, Tasting=25ml
*/
private function createWine(BusinessSetting $setting, string $name, string $sku, string $category, float $bottleCost, array $items): ProductSheet
{
// Standard wine volumes
$baseVolume = 750; // ml per bottle
$glassVolume = 100; // ml per standard glass (taça)
$product = ProductSheet::create([
'user_id' => $setting->user_id,
'business_setting_id' => $setting->id,
'name' => $name,
'sku' => $sku,
'category' => $category,
'currency' => $setting->currency,
'base_volume_ml' => $baseVolume,
'standard_portion_ml' => $glassVolume,
'is_active' => true,
]);
foreach ($items as $item) {
ProductSheetItem::create([
'product_sheet_id' => $product->id,
'name' => $item['name'],
'amount' => $item['cost'],
'quantity' => $item['quantity'],
'unit_cost' => $item['cost'],
'type' => 'product_cost',
]);
}
// Recalculate totals
$product->refresh();
$product->cmv_total = $product->items->sum(fn($i) => ($i->amount ?? $i->unit_cost) * $i->quantity);
$product->sale_price = $product->calculateSalePrice($setting);
$product->final_price = $product->calculateFinalPrice();
$product->save();
// Create 4 variants for wine
$markup = $setting->markup_calculated ?? 2.5;
$cmvTotal = $product->cmv_total;
// 1. Garrafa (Bottle) - full unit
$this->createVariant($product, 'Garrafa', 1.0, 1, $baseVolume, $cmvTotal, $markup, true, 1);
// 2. Taça (Glass) - 100ml = 7.5 glasses per bottle
$glassRatio = $glassVolume / $baseVolume; // 0.1333
$glassesPerBottle = $baseVolume / $glassVolume; // 7.5
$this->createVariant($product, 'Taça', $glassRatio, $glassesPerBottle, $glassVolume, $cmvTotal, $markup, false, 2);
// 3. Meia Taça (Half Glass) - 50ml = 15 per bottle
$halfGlassVolume = $glassVolume / 2; // 50ml
$halfGlassRatio = $halfGlassVolume / $baseVolume; // 0.0667
$halfGlassesPerBottle = $baseVolume / $halfGlassVolume; // 15
$this->createVariant($product, 'Meia Taça', $halfGlassRatio, $halfGlassesPerBottle, $halfGlassVolume, $cmvTotal, $markup, false, 3);
// 4. Degustação (Tasting) - 25ml = 30 per bottle
$tastingVolume = $glassVolume / 4; // 25ml
$tastingRatio = $tastingVolume / $baseVolume; // 0.0333
$tastingsPerBottle = $baseVolume / $tastingVolume; // 30
$this->createVariant($product, 'Degustação', $tastingRatio, $tastingsPerBottle, $tastingVolume, $cmvTotal, $markup, false, 4);
return $product;
}
/**
* Create a product variant
*/
private function createVariant(ProductSheet $product, string $name, float $portionRatio, float $quantityPerUnit, float $volumeMl, float $cmvTotal, float $markup, bool $isDefault, int $sortOrder): ProductVariant
{
$cmvPortion = $cmvTotal * $portionRatio;
$salePrice = $cmvPortion * $markup;
$contributionMargin = $salePrice - $cmvPortion;
$marginPercentage = $salePrice > 0 ? ($contributionMargin / $salePrice) * 100 : 0;
return ProductVariant::create([
'product_sheet_id' => $product->id,
'name' => $name,
'portion_ratio' => $portionRatio,
'quantity_per_unit' => $quantityPerUnit,
'volume_ml' => $volumeMl,
'cmv_portion' => $cmvPortion,
'markup' => $markup,
'sale_price' => round($salePrice, 2),
'contribution_margin' => round($contributionMargin, 2),
'margin_percentage' => round($marginPercentage, 2),
'is_default' => $isDefault,
'sort_order' => $sortOrder,
]);
}
}

View File

@ -13,6 +13,8 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
sku: '',
description: '',
category: '',
base_volume_ml: '',
standard_portion_ml: '',
currency: 'EUR',
business_setting_id: '',
is_active: true,
@ -27,9 +29,11 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
});
const [items, setItems] = useState([]);
const [variants, setVariants] = useState([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [showStrategicPricing, setShowStrategicPricing] = useState(false);
const [showVariants, setShowVariants] = useState(false);
const itemTypes = [
{ value: 'product_cost', label: t('business.products.itemTypes.productCost') },
@ -47,6 +51,8 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
sku: sheet.sku || '',
description: sheet.description || '',
category: sheet.category || '',
base_volume_ml: sheet.base_volume_ml || '',
standard_portion_ml: sheet.standard_portion_ml || '',
currency: sheet.currency || 'EUR',
business_setting_id: sheet.business_setting_id || '',
is_active: sheet.is_active ?? true,
@ -67,11 +73,25 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
quantity: item.quantity || 1,
unit: item.unit || '',
})) || []);
// Load variants
setVariants(sheet.variants?.map(v => ({
id: v.id,
name: v.name,
portion_ratio: v.portion_ratio,
quantity_per_unit: v.quantity_per_unit || '',
volume_ml: v.volume_ml || '',
markup: v.markup || '',
is_default: v.is_default || false,
})) || []);
// 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);
}
// Show variants if any exist
if (sheet.variants?.length > 0) {
setShowVariants(true);
}
}
}, [sheet]);
@ -99,6 +119,44 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
}]);
};
// Variantes
const handleVariantChange = (index, field, value) => {
setVariants(prev => prev.map((v, i) =>
i === index ? { ...v, [field]: value } : v
));
};
const addVariant = () => {
setVariants(prev => [...prev, {
name: '',
portion_ratio: 1,
quantity_per_unit: '',
volume_ml: '',
markup: '',
is_default: prev.length === 0,
}]);
};
const removeVariant = (index) => {
setVariants(prev => prev.filter((_, i) => i !== index));
};
// Adicionar variantes padrão para vinho
const addWineVariants = () => {
const baseVolume = parseInt(formData.base_volume_ml) || 750;
const portionMl = parseInt(formData.standard_portion_ml) || 100;
const glassesPerBottle = baseVolume / portionMl;
setVariants([
{ name: t('business.variants.bottle'), portion_ratio: 1, quantity_per_unit: 1, volume_ml: baseVolume, markup: '', is_default: true },
{ name: t('business.variants.glass'), portion_ratio: (portionMl / baseVolume).toFixed(4), quantity_per_unit: glassesPerBottle.toFixed(1), volume_ml: portionMl, markup: '', is_default: false },
{ name: t('business.variants.halfGlass'), portion_ratio: ((portionMl / 2) / baseVolume).toFixed(4), quantity_per_unit: (glassesPerBottle * 2).toFixed(1), volume_ml: Math.round(portionMl / 2), markup: '', is_default: false },
{ name: t('business.variants.tasting'), portion_ratio: ((portionMl / 4) / baseVolume).toFixed(4), quantity_per_unit: (glassesPerBottle * 4).toFixed(1), volume_ml: Math.round(portionMl / 4), markup: '', is_default: false },
]);
setShowVariants(true);
};
const removeItem = (index) => {
setItems(prev => prev.filter((_, i) => i !== index));
};
@ -120,6 +178,15 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
return calculateCmv() * setting.markup_factor;
};
// Calcular preço de variante
const calculateVariantPrice = (variant) => {
const cmv = calculateCmv();
const portionCmv = cmv * parseFloat(variant.portion_ratio || 1);
const setting = settings.find(s => s.id === parseInt(formData.business_setting_id));
const markup = parseFloat(variant.markup) || setting?.markup_factor || 2.5;
return portionCmv * markup;
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
@ -131,6 +198,8 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
try {
const dataToSend = {
...formData,
base_volume_ml: formData.base_volume_ml ? parseInt(formData.base_volume_ml) : null,
standard_portion_ml: formData.standard_portion_ml ? parseInt(formData.standard_portion_ml) : null,
business_setting_id: formData.business_setting_id || null,
items: validItems.map(item => ({
name: item.name,
@ -139,6 +208,14 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
quantity: parseFloat(item.quantity) || 1,
unit: item.unit || null,
})),
variants: variants.filter(v => v.name).map(v => ({
name: v.name,
portion_ratio: parseFloat(v.portion_ratio) || 1,
quantity_per_unit: v.quantity_per_unit ? parseFloat(v.quantity_per_unit) : null,
volume_ml: v.volume_ml ? parseInt(v.volume_ml) : null,
markup: v.markup ? parseFloat(v.markup) : null,
is_default: v.is_default || false,
})),
};
let result;
@ -149,6 +226,8 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
sku: formData.sku,
description: formData.description,
category: formData.category,
base_volume_ml: formData.base_volume_ml ? parseInt(formData.base_volume_ml) : null,
standard_portion_ml: formData.standard_portion_ml ? parseInt(formData.standard_portion_ml) : null,
currency: formData.currency,
business_setting_id: formData.business_setting_id || null,
is_active: formData.is_active,
@ -160,6 +239,16 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
price_strategy: formData.price_strategy,
psychological_rounding: formData.psychological_rounding,
target_margin_percent: formData.target_margin_percent ? parseFloat(formData.target_margin_percent) : null,
// Variants
variants: variants.filter(v => v.name).map(v => ({
id: v.id || null,
name: v.name,
portion_ratio: parseFloat(v.portion_ratio) || 1,
quantity_per_unit: v.quantity_per_unit ? parseFloat(v.quantity_per_unit) : null,
volume_ml: v.volume_ml ? parseInt(v.volume_ml) : null,
markup: v.markup ? parseFloat(v.markup) : null,
is_default: v.is_default || false,
})),
});
// Remover itens que não existem mais
@ -566,6 +655,168 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
</div>
</div>
{/* Variantes de Venda (ex: Garrafa, Taça, Meia Taça) */}
<div className="col-12">
<hr className="border-secondary my-2" />
<button
type="button"
className="btn btn-sm btn-outline-warning w-100"
onClick={() => setShowVariants(!showVariants)}
>
<i className={`bi bi-chevron-${showVariants ? 'up' : 'down'} me-2`}></i>
{t('business.variants.title')}
<i className="bi bi-cup-straw ms-2"></i>
</button>
</div>
{showVariants && (
<div className="col-12">
{/* Volume Settings */}
<div className="row g-2 mb-3">
<div className="col-md-4">
<label className="form-label text-slate-400 small">
<i className="bi bi-droplet me-1"></i>
{t('business.variants.baseVolume')}
</label>
<div className="input-group input-group-sm">
<input
type="number"
className="form-control bg-dark text-white border-secondary"
name="base_volume_ml"
value={formData.base_volume_ml}
onChange={handleChange}
placeholder="750"
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">ml</span>
</div>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400 small">
<i className="bi bi-cup me-1"></i>
{t('business.variants.portionSize')}
</label>
<div className="input-group input-group-sm">
<input
type="number"
className="form-control bg-dark text-white border-secondary"
name="standard_portion_ml"
value={formData.standard_portion_ml}
onChange={handleChange}
placeholder="100"
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">ml</span>
</div>
</div>
<div className="col-md-4 d-flex align-items-end">
<button
type="button"
className="btn btn-sm btn-outline-warning w-100"
onClick={addWineVariants}
disabled={!formData.base_volume_ml}
>
<i className="bi bi-magic me-1"></i>
{t('business.variants.autoGenerate')}
</button>
</div>
</div>
{/* Variants Table */}
<div className="d-flex justify-content-between align-items-center mb-2">
<small className="text-slate-500">{t('business.variants.description')}</small>
<button type="button" className="btn btn-sm btn-outline-primary" onClick={addVariant}>
<i className="bi bi-plus-lg me-1"></i>
{t('business.variants.add')}
</button>
</div>
{variants.length === 0 ? (
<div className="text-center py-3 text-slate-500">
<i className="bi bi-cup fs-4 mb-2 d-block"></i>
{t('business.variants.noVariants')}
</div>
) : (
<div className="table-responsive">
<table className="table table-dark table-sm mb-0">
<thead>
<tr>
<th className="text-slate-400">{t('business.variants.name')}</th>
<th className="text-slate-400 text-center">{t('business.variants.volume')}</th>
<th className="text-slate-400 text-center">{t('business.variants.qty')}</th>
<th className="text-slate-400 text-end">{t('business.common.cmv')}</th>
<th className="text-slate-400 text-center">{t('business.common.markup')}</th>
<th className="text-slate-400 text-end">{t('business.variants.pvp')}</th>
<th style={{ width: '40px' }}></th>
</tr>
</thead>
<tbody>
{variants.map((variant, index) => {
const portionCmv = cmvTotal * parseFloat(variant.portion_ratio || 1);
const setting = settings.find(s => s.id === parseInt(formData.business_setting_id));
const variantMarkup = parseFloat(variant.markup) || setting?.markup_factor || 2.5;
const variantPrice = portionCmv * variantMarkup;
return (
<tr key={index}>
<td>
<input
type="text"
className="form-control form-control-sm bg-dark text-white border-secondary"
value={variant.name}
onChange={(e) => handleVariantChange(index, 'name', e.target.value)}
placeholder={t('business.variants.namePlaceholder')}
/>
</td>
<td>
<div className="input-group input-group-sm">
<input
type="number"
className="form-control bg-dark text-white border-secondary text-center"
value={variant.volume_ml}
onChange={(e) => handleVariantChange(index, 'volume_ml', e.target.value)}
placeholder="ml"
style={{ width: '60px' }}
/>
</div>
</td>
<td className="text-center text-slate-400 align-middle">
{variant.quantity_per_unit ? `${variant.quantity_per_unit}×` : '-'}
</td>
<td className="text-end text-danger align-middle">
{currency(portionCmv, formData.currency)}
</td>
<td>
<input
type="number"
step="0.1"
min="1"
className="form-control form-control-sm bg-dark text-white border-secondary text-center"
value={variant.markup}
onChange={(e) => handleVariantChange(index, 'markup', e.target.value)}
placeholder={setting?.markup_factor?.toFixed(2) || '2.50'}
style={{ width: '70px' }}
/>
</td>
<td className="text-end text-success align-middle fw-bold">
{currency(variantPrice, formData.currency)}
</td>
<td>
<button
type="button"
className="btn btn-sm btn-outline-danger border-0"
onClick={() => removeVariant(index)}
>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Status */}
{isEditing && (
<div className="col-12">

View File

@ -1343,6 +1343,24 @@
"noItems": "No cost components",
"addFirst": "Add the costs that make up the COGS"
},
"variants": {
"title": "Sale Variants",
"description": "Configure different sale options (e.g.: bottle, glass, half glass)",
"add": "Add Variant",
"noVariants": "No variants configured",
"name": "Name",
"namePlaceholder": "E.g.: Glass",
"volume": "Volume",
"qty": "Qty",
"pvp": "Price",
"baseVolume": "Base Volume",
"portionSize": "Standard Portion",
"autoGenerate": "Generate for Wine",
"bottle": "Bottle",
"glass": "Glass",
"halfGlass": "Half Glass",
"tasting": "Tasting"
},
"calculator": {
"title": "Price Calculator",
"description": "Simulate sale prices from COGS and markup settings",

View File

@ -1343,6 +1343,24 @@
"noItems": "Sin componentes de costo",
"addFirst": "Agrega los costos que componen el CMV"
},
"variants": {
"title": "Variantes de Venta",
"description": "Configura diferentes opciones de venta (ej: botella, copa, media copa)",
"add": "Agregar Variante",
"noVariants": "Sin variantes configuradas",
"name": "Nombre",
"namePlaceholder": "Ej: Copa",
"volume": "Volumen",
"qty": "Cant",
"pvp": "PVP",
"baseVolume": "Volumen Base",
"portionSize": "Porción Estándar",
"autoGenerate": "Generar para Vino",
"bottle": "Botella",
"glass": "Copa",
"halfGlass": "Media Copa",
"tasting": "Degustación"
},
"calculator": {
"title": "Calculadora de Precios",
"description": "Simula precios de venta a partir del CMV y la configuración de markup",

View File

@ -1345,6 +1345,24 @@
"noItems": "Sem componentes de custo",
"addFirst": "Adicione os custos que compõem o CMV"
},
"variants": {
"title": "Variantes de Venda",
"description": "Configure diferentes opções de venda (ex: garrafa, taça, meia taça)",
"add": "Adicionar Variante",
"noVariants": "Sem variantes configuradas",
"name": "Nome",
"namePlaceholder": "Ex: Taça",
"volume": "Volume",
"qty": "Qtd",
"pvp": "PVP",
"baseVolume": "Volume Base",
"portionSize": "Porção Padrão",
"autoGenerate": "Gerar para Vinho",
"bottle": "Garrafa",
"glass": "Taça",
"halfGlass": "Meia Taça",
"tasting": "Degustação"
},
"calculator": {
"title": "Calculadora de Preços",
"description": "Simule preços de venda a partir do CMV e da configuração de markup",