diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ded634..a23eb76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 034552a..34aae15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.30.0 +1.31.0 diff --git a/backend/app/Http/Controllers/Api/ProductSheetController.php b/backend/app/Http/Controllers/Api/ProductSheetController.php index a96a6c1..0767d11 100644 --- a/backend/app/Http/Controllers/Api/ProductSheetController.php +++ b/backend/app/Http/Controllers/Api/ProductSheetController.php @@ -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, diff --git a/backend/app/Models/ProductSheet.php b/backend/app/Models/ProductSheet.php index 48bec1b..85f3a47 100644 --- a/backend/app/Models/ProductSheet.php +++ b/backend/app/Models/ProductSheet.php @@ -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 * diff --git a/backend/app/Models/ProductVariant.php b/backend/app/Models/ProductVariant.php new file mode 100644 index 0000000..7f3691a --- /dev/null +++ b/backend/app/Models/ProductVariant.php @@ -0,0 +1,75 @@ + '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; + } + } +} diff --git a/backend/database/migrations/2025_12_14_140001_create_product_variants_table.php b/backend/database/migrations/2025_12_14_140001_create_product_variants_table.php new file mode 100644 index 0000000..895b52b --- /dev/null +++ b/backend/database/migrations/2025_12_14_140001_create_product_variants_table.php @@ -0,0 +1,76 @@ +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']); + }); + } +}; diff --git a/backend/database/seeders/BusinessExampleSeeder.php b/backend/database/seeders/BusinessExampleSeeder.php index f4deb26..02fa280 100644 --- a/backend/database/seeders/BusinessExampleSeeder.php +++ b/backend/database/seeders/BusinessExampleSeeder.php @@ -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, + ]); + } } diff --git a/frontend/src/components/business/ProductSheetModal.jsx b/frontend/src/components/business/ProductSheetModal.jsx index 494b615..5d56d28 100644 --- a/frontend/src/components/business/ProductSheetModal.jsx +++ b/frontend/src/components/business/ProductSheetModal.jsx @@ -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 }) => { + {/* Variantes de Venda (ex: Garrafa, Taça, Meia Taça) */} +
+
+ +
+ + {showVariants && ( +
+ {/* Volume Settings */} +
+
+ +
+ + ml +
+
+
+ +
+ + ml +
+
+
+ +
+
+ + {/* Variants Table */} +
+ {t('business.variants.description')} + +
+ + {variants.length === 0 ? ( +
+ + {t('business.variants.noVariants')} +
+ ) : ( +
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + ); + })} + +
{t('business.variants.name')}{t('business.variants.volume')}{t('business.variants.qty')}{t('business.common.cmv')}{t('business.common.markup')}{t('business.variants.pvp')}
+ handleVariantChange(index, 'name', e.target.value)} + placeholder={t('business.variants.namePlaceholder')} + /> + +
+ handleVariantChange(index, 'volume_ml', e.target.value)} + placeholder="ml" + style={{ width: '60px' }} + /> +
+
+ {variant.quantity_per_unit ? `${variant.quantity_per_unit}×` : '-'} + + {currency(portionCmv, formData.currency)} + + handleVariantChange(index, 'markup', e.target.value)} + placeholder={setting?.markup_factor?.toFixed(2) || '2.50'} + style={{ width: '70px' }} + /> + + {currency(variantPrice, formData.currency)} + + +
+
+ )} +
+ )} + {/* Status */} {isEditing && (
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c509a40..3b5836e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 8de72cc..168c795 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index a915796..9a75810 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -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",