diff --git a/CHANGELOG.md b/CHANGELOG.md index 221e69b..1db5053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.28.0] - 2025-12-14 + +### Added +- **Nueva Sección Business** - Módulo completo para cálculo de precios con Markup + - **Configuraciones de Markup**: Define parámetros de cada unidad de negocio + - Facturación mensual y gastos fijos + - Costos variables: impuestos, comisiones, tasas de tarjeta + - Tasa de inversión y margen de ganancia + - Cálculo automático del factor de markup: `Markup = 1 / (1 - deductions)` + + - **Fichas Técnicas de Productos**: Gestión del CMV (Costo de Mercancía Vendida) + - Componentes de costo: producto, embalaje, etiqueta, envío, manipulación, otros + - Cálculo automático del CMV total y precio de venta sugerido + - Margen de contribución calculado en tiempo real + - Función duplicar para crear variantes rápidamente + + - **Calculadora de Precios**: Simulador interactivo + - Ingresa un CMV y visualiza precio de venta instantáneamente + - Desglose completo de la configuración seleccionada + - Tabla de precios rápidos para productos existentes + - Fórmula visible: `Precio de Venta = CMV × Markup` + + - **Multi-divisa**: Soporte completo para EUR, BRL, USD + +### Technical +- Backend: 3 migraciones, 3 modelos, 2 controladores con endpoints especializados +- Frontend: Página Business.jsx con 3 tabs y 5 componentes React +- API: Routes para business-settings y product-sheets con CRUD completo +- i18n: Traducciones completas en ES, PT-BR, EN + + ## [1.27.6] - 2025-12-13 ### Fixed diff --git a/VERSION b/VERSION index 2a5aed4..cfc7307 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.27.6 +1.28.0 diff --git a/backend/app/Http/Controllers/Api/BusinessSettingController.php b/backend/app/Http/Controllers/Api/BusinessSettingController.php new file mode 100644 index 0000000..aa09057 --- /dev/null +++ b/backend/app/Http/Controllers/Api/BusinessSettingController.php @@ -0,0 +1,215 @@ +user()->id; + + $settings = BusinessSetting::ofUser($userId) + ->orderBy('is_active', 'desc') + ->orderBy('name') + ->get() + ->map(function ($setting) { + return array_merge($setting->toArray(), [ + 'fixed_expenses_rate' => $setting->fixed_expenses_rate, + 'total_variable_costs' => $setting->total_variable_costs, + 'markup_breakdown' => $setting->markup_breakdown, + ]); + }); + + return response()->json($settings); + } + + /** + * Cria uma nova configuração de negócio + */ + public function store(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'currency' => 'required|string|size:3', + 'monthly_revenue' => 'required|numeric|min:0', + 'fixed_expenses' => 'required|numeric|min:0', + 'tax_rate' => 'required|numeric|min:0|max:100', + 'sales_commission' => 'required|numeric|min:0|max:100', + 'card_fee' => 'required|numeric|min:0|max:100', + 'other_variable_costs' => 'nullable|numeric|min:0|max:100', + 'investment_rate' => 'required|numeric|min:0|max:100', + 'profit_margin' => 'required|numeric|min:0|max:100', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $data = $validator->validated(); + $data['user_id'] = $request->user()->id; + $data['other_variable_costs'] = $data['other_variable_costs'] ?? 0; + + $setting = BusinessSetting::create($data); + + // Calcular e salvar o markup + $setting->recalculateMarkup(); + + // Recarregar com os dados calculados + $setting->refresh(); + + return response()->json(array_merge($setting->toArray(), [ + 'fixed_expenses_rate' => $setting->fixed_expenses_rate, + 'total_variable_costs' => $setting->total_variable_costs, + 'markup_breakdown' => $setting->markup_breakdown, + ]), 201); + } + + /** + * Exibe uma configuração específica + */ + public function show(Request $request, $id): JsonResponse + { + $setting = BusinessSetting::ofUser($request->user()->id) + ->with('productSheets') + ->findOrFail($id); + + return response()->json(array_merge($setting->toArray(), [ + 'fixed_expenses_rate' => $setting->fixed_expenses_rate, + 'total_variable_costs' => $setting->total_variable_costs, + 'markup_breakdown' => $setting->markup_breakdown, + ])); + } + + /** + * Atualiza uma configuração + */ + public function update(Request $request, $id): JsonResponse + { + $setting = BusinessSetting::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'sometimes|string|max:255', + 'currency' => 'sometimes|string|size:3', + 'monthly_revenue' => 'sometimes|numeric|min:0', + 'fixed_expenses' => 'sometimes|numeric|min:0', + 'tax_rate' => 'sometimes|numeric|min:0|max:100', + 'sales_commission' => 'sometimes|numeric|min:0|max:100', + 'card_fee' => 'sometimes|numeric|min:0|max:100', + 'other_variable_costs' => 'sometimes|numeric|min:0|max:100', + 'investment_rate' => 'sometimes|numeric|min:0|max:100', + 'profit_margin' => 'sometimes|numeric|min:0|max:100', + 'is_active' => 'sometimes|boolean', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $setting->update($validator->validated()); + + // Recalcular markup + $setting->recalculateMarkup(); + + $setting->refresh(); + + return response()->json(array_merge($setting->toArray(), [ + 'fixed_expenses_rate' => $setting->fixed_expenses_rate, + 'total_variable_costs' => $setting->total_variable_costs, + 'markup_breakdown' => $setting->markup_breakdown, + ])); + } + + /** + * Remove uma configuração + */ + public function destroy(Request $request, $id): JsonResponse + { + $setting = BusinessSetting::ofUser($request->user()->id)->findOrFail($id); + + // Verificar se há fichas técnicas vinculadas + if ($setting->productSheets()->count() > 0) { + return response()->json([ + 'message' => 'Não é possível excluir: existem fichas técnicas vinculadas a esta configuração.' + ], 422); + } + + $setting->delete(); + + return response()->json(['message' => 'Configuração excluída com sucesso']); + } + + /** + * Recalcula o markup de uma configuração + */ + public function recalculateMarkup(Request $request, $id): JsonResponse + { + $setting = BusinessSetting::ofUser($request->user()->id)->findOrFail($id); + + $markup = $setting->recalculateMarkup(); + + return response()->json([ + 'markup_factor' => $markup, + 'markup_breakdown' => $setting->markup_breakdown, + ]); + } + + /** + * Simula um preço de venda dado um CMV + */ + public function simulatePrice(Request $request, $id): JsonResponse + { + $setting = BusinessSetting::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'cmv' => 'required|numeric|min:0', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $cmv = $request->cmv; + $salePrice = $setting->calculateSalePrice($cmv); + $contributionMargin = $salePrice - $cmv; + $contributionMarginPercent = $salePrice > 0 ? round(($contributionMargin / $salePrice) * 100, 2) : 0; + + return response()->json([ + 'cmv' => $cmv, + 'markup_factor' => $setting->markup_factor, + 'sale_price' => $salePrice, + 'contribution_margin' => $contributionMargin, + 'contribution_margin_percent' => $contributionMarginPercent, + 'currency' => $setting->currency, + 'breakdown' => $setting->markup_breakdown, + ]); + } + + /** + * Retorna a configuração ativa padrão do usuário + */ + public function getDefault(Request $request): JsonResponse + { + $setting = BusinessSetting::ofUser($request->user()->id) + ->active() + ->first(); + + if (!$setting) { + return response()->json(['message' => 'Nenhuma configuração ativa encontrada'], 404); + } + + return response()->json(array_merge($setting->toArray(), [ + 'fixed_expenses_rate' => $setting->fixed_expenses_rate, + 'total_variable_costs' => $setting->total_variable_costs, + 'markup_breakdown' => $setting->markup_breakdown, + ])); + } +} diff --git a/backend/app/Http/Controllers/Api/ProductSheetController.php b/backend/app/Http/Controllers/Api/ProductSheetController.php new file mode 100644 index 0000000..f2cd142 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ProductSheetController.php @@ -0,0 +1,398 @@ +user()->id; + + $query = ProductSheet::ofUser($userId) + ->with(['items', 'businessSetting:id,name,currency,markup_factor']); + + // Filtro por categoria + if ($request->has('category')) { + $query->byCategory($request->category); + } + + // Filtro por status + if ($request->has('active')) { + $query->where('is_active', $request->boolean('active')); + } + + // Ordenação + $sortBy = $request->get('sort_by', 'name'); + $sortDir = $request->get('sort_dir', 'asc'); + $query->orderBy($sortBy, $sortDir); + + $sheets = $query->get()->map(function ($sheet) { + return array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ]); + }); + + return response()->json($sheets); + } + + /** + * Cria uma nova ficha técnica + */ + public function store(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'sku' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'currency' => 'required|string|size:3', + 'business_setting_id' => 'nullable|exists:business_settings,id', + 'items' => 'nullable|array', + 'items.*.name' => 'required|string|max:255', + 'items.*.type' => 'required|in:product_cost,packaging,label,shipping,handling,other', + 'items.*.amount' => 'required|numeric|min:0', + 'items.*.quantity' => 'nullable|numeric|min:0', + 'items.*.unit' => 'nullable|string|max:20', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $userId = $request->user()->id; + + // Verificar se business_setting_id pertence ao usuário + if ($request->business_setting_id) { + $setting = BusinessSetting::ofUser($userId)->find($request->business_setting_id); + if (!$setting) { + return response()->json(['message' => 'Configuração de negócio não encontrada'], 404); + } + } + + DB::beginTransaction(); + + try { + // Criar a ficha técnica + $sheet = ProductSheet::create([ + 'user_id' => $userId, + 'business_setting_id' => $request->business_setting_id, + 'name' => $request->name, + 'sku' => $request->sku, + 'description' => $request->description, + 'category' => $request->category, + 'currency' => $request->currency, + ]); + + // Criar os itens + if ($request->has('items') && is_array($request->items)) { + foreach ($request->items as $index => $itemData) { + ProductSheetItem::create([ + 'product_sheet_id' => $sheet->id, + 'name' => $itemData['name'], + 'type' => $itemData['type'], + 'amount' => $itemData['amount'], + 'quantity' => $itemData['quantity'] ?? 1, + 'unit' => $itemData['unit'] ?? null, + 'sort_order' => $index, + ]); + } + } + + // Recalcular CMV + $sheet->recalculateCmv(); + + // Calcular preço de venda se tiver configuração + if ($sheet->business_setting_id) { + $sheet->calculateSalePrice(); + } + + DB::commit(); + + // Recarregar com relacionamentos + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ]), 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['message' => 'Erro ao criar ficha técnica: ' . $e->getMessage()], 500); + } + } + + /** + * Exibe uma ficha técnica específica + */ + public function show(Request $request, $id): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id) + ->with(['items', 'businessSetting']) + ->findOrFail($id); + + return response()->json(array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ])); + } + + /** + * Atualiza uma ficha técnica + */ + public function update(Request $request, $id): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'sometimes|string|max:255', + 'sku' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'currency' => 'sometimes|string|size:3', + 'business_setting_id' => 'nullable|exists:business_settings,id', + 'is_active' => 'sometimes|boolean', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + // Verificar se business_setting_id pertence ao usuário + if ($request->has('business_setting_id') && $request->business_setting_id) { + $setting = BusinessSetting::ofUser($request->user()->id)->find($request->business_setting_id); + if (!$setting) { + return response()->json(['message' => 'Configuração de negócio não encontrada'], 404); + } + } + + $sheet->update($validator->validated()); + + // Recalcular preço se mudou a configuração + if ($request->has('business_setting_id') && $sheet->business_setting_id) { + $sheet->calculateSalePrice(); + } + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ])); + } + + /** + * Remove uma ficha técnica + */ + public function destroy(Request $request, $id): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id)->findOrFail($id); + $sheet->delete(); + + return response()->json(['message' => 'Ficha técnica excluída com sucesso']); + } + + /** + * Adiciona um item à ficha técnica + */ + public function addItem(Request $request, $id): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'type' => 'required|in:product_cost,packaging,label,shipping,handling,other', + 'amount' => 'required|numeric|min:0', + 'quantity' => 'nullable|numeric|min:0', + 'unit' => 'nullable|string|max:20', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $maxOrder = $sheet->items()->max('sort_order') ?? -1; + + $item = ProductSheetItem::create([ + 'product_sheet_id' => $sheet->id, + 'name' => $request->name, + 'type' => $request->type, + 'amount' => $request->amount, + 'quantity' => $request->quantity ?? 1, + 'unit' => $request->unit, + 'sort_order' => $maxOrder + 1, + ]); + + // Recalcular preço de venda + if ($sheet->business_setting_id) { + $sheet->calculateSalePrice(); + } + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ])); + } + + /** + * Atualiza um item da ficha técnica + */ + public function updateItem(Request $request, $sheetId, $itemId): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id)->findOrFail($sheetId); + $item = ProductSheetItem::where('product_sheet_id', $sheet->id)->findOrFail($itemId); + + $validator = Validator::make($request->all(), [ + 'name' => 'sometimes|string|max:255', + 'type' => 'sometimes|in:product_cost,packaging,label,shipping,handling,other', + 'amount' => 'sometimes|numeric|min:0', + 'quantity' => 'sometimes|numeric|min:0', + 'unit' => 'nullable|string|max:20', + 'sort_order' => 'sometimes|integer|min:0', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $item->update($validator->validated()); + + // Recalcular preço de venda + if ($sheet->business_setting_id) { + $sheet->calculateSalePrice(); + } + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ])); + } + + /** + * Remove um item da ficha técnica + */ + public function removeItem(Request $request, $sheetId, $itemId): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id)->findOrFail($sheetId); + $item = ProductSheetItem::where('product_sheet_id', $sheet->id)->findOrFail($itemId); + + $item->delete(); + + // Recalcular preço de venda + if ($sheet->business_setting_id) { + $sheet->calculateSalePrice(); + } + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ])); + } + + /** + * Recalcula o preço de venda de uma ficha + */ + public function recalculatePrice(Request $request, $id): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id)->findOrFail($id); + + // Pode usar uma configuração diferente + $settingId = $request->business_setting_id; + $setting = null; + + if ($settingId) { + $setting = BusinessSetting::ofUser($request->user()->id)->findOrFail($settingId); + } elseif ($sheet->business_setting_id) { + $setting = $sheet->businessSetting; + } + + if (!$setting) { + return response()->json(['message' => 'Nenhuma configuração de negócio disponível'], 422); + } + + $salePrice = $sheet->calculateSalePrice($setting); + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'contribution_margin_percent' => $sheet->contribution_margin_percent, + ])); + } + + /** + * Lista as categorias de produtos do usuário + */ + public function categories(Request $request): JsonResponse + { + $categories = ProductSheet::ofUser($request->user()->id) + ->whereNotNull('category') + ->distinct() + ->pluck('category'); + + return response()->json($categories); + } + + /** + * Lista os tipos de componentes disponíveis + */ + public function itemTypes(): JsonResponse + { + return response()->json(ProductSheetItem::getTypes()); + } + + /** + * Duplica uma ficha técnica + */ + public function duplicate(Request $request, $id): JsonResponse + { + $sheet = ProductSheet::ofUser($request->user()->id) + ->with('items') + ->findOrFail($id); + + DB::beginTransaction(); + + try { + // Criar cópia da ficha + $newSheet = $sheet->replicate(); + $newSheet->name = $sheet->name . ' (cópia)'; + $newSheet->save(); + + // Copiar os itens + foreach ($sheet->items as $item) { + $newItem = $item->replicate(); + $newItem->product_sheet_id = $newSheet->id; + $newItem->save(); + } + + // Recalcular + $newSheet->recalculateCmv(); + if ($newSheet->business_setting_id) { + $newSheet->calculateSalePrice(); + } + + DB::commit(); + + $newSheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($newSheet->toArray(), [ + 'contribution_margin_percent' => $newSheet->contribution_margin_percent, + ]), 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['message' => 'Erro ao duplicar ficha técnica: ' . $e->getMessage()], 500); + } + } +} diff --git a/backend/app/Models/BusinessSetting.php b/backend/app/Models/BusinessSetting.php new file mode 100644 index 0000000..3082aa7 --- /dev/null +++ b/backend/app/Models/BusinessSetting.php @@ -0,0 +1,180 @@ + 'decimal:2', + 'fixed_expenses' => 'decimal:2', + 'tax_rate' => 'decimal:2', + 'sales_commission' => 'decimal:2', + 'card_fee' => 'decimal:2', + 'other_variable_costs' => 'decimal:2', + 'investment_rate' => 'decimal:2', + 'profit_margin' => 'decimal:2', + 'markup_factor' => 'decimal:4', + 'is_active' => 'boolean', + ]; + + /** + * Relacionamento com usuário + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Fichas técnicas que usam esta configuração + */ + public function productSheets(): HasMany + { + return $this->hasMany(ProductSheet::class); + } + + /** + * Calcula o percentual de despesas fixas sobre a receita + * + * @return float + */ + public function getFixedExpensesRateAttribute(): float + { + if ($this->monthly_revenue <= 0) { + return 0; + } + + return round(($this->fixed_expenses / $this->monthly_revenue) * 100, 2); + } + + /** + * Calcula o total de custos variáveis (sem CMV) + * + * @return float + */ + public function getTotalVariableCostsAttribute(): float + { + return $this->tax_rate + + $this->sales_commission + + $this->card_fee + + $this->other_variable_costs; + } + + /** + * Calcula o fator de Markup usando a fórmula: + * Markup = 1 / (1 - (Despesas Fixas % + Custos Variáveis % + Investimento % + Lucro %)) + * + * @return float + */ + public function calculateMarkup(): float + { + // Converter percentuais para decimais + $fixedExpensesRate = $this->fixed_expenses_rate / 100; + $variableCosts = $this->total_variable_costs / 100; + $investmentRate = $this->investment_rate / 100; + $profitMargin = $this->profit_margin / 100; + + // Total de deduções + $totalDeductions = $fixedExpensesRate + $variableCosts + $investmentRate + $profitMargin; + + // Verificar se é possível calcular (não pode ser >= 100%) + if ($totalDeductions >= 1) { + return 0; // Markup inválido - deduções >= 100% + } + + // Fórmula: Markup = 1 / (1 - deduções) + $markup = 1 / (1 - $totalDeductions); + + return round($markup, 4); + } + + /** + * Recalcula e salva o markup + * + * @return float + */ + public function recalculateMarkup(): float + { + $this->markup_factor = $this->calculateMarkup(); + $this->save(); + + return $this->markup_factor; + } + + /** + * Calcula o preço de venda para um CMV dado + * + * @param float $cmv Custo da Mercadoria Vendida + * @return float + */ + public function calculateSalePrice(float $cmv): float + { + $markup = $this->markup_factor ?? $this->calculateMarkup(); + + if ($markup <= 0) { + return 0; + } + + return round($cmv * $markup, 2); + } + + /** + * Retorna o breakdown do Markup para exibição + * + * @return array + */ + public function getMarkupBreakdownAttribute(): array + { + return [ + 'fixed_expenses_rate' => $this->fixed_expenses_rate, + 'tax_rate' => (float) $this->tax_rate, + 'sales_commission' => (float) $this->sales_commission, + 'card_fee' => (float) $this->card_fee, + 'other_variable_costs' => (float) $this->other_variable_costs, + 'total_variable_costs' => $this->total_variable_costs, + 'investment_rate' => (float) $this->investment_rate, + 'profit_margin' => (float) $this->profit_margin, + 'total_deductions' => $this->fixed_expenses_rate + $this->total_variable_costs + $this->investment_rate + $this->profit_margin, + 'markup_factor' => (float) ($this->markup_factor ?? $this->calculateMarkup()), + ]; + } + + /** + * Scope para buscar configurações ativas do usuário + */ + public function scopeOfUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope para buscar apenas configurações ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/backend/app/Models/ProductSheet.php b/backend/app/Models/ProductSheet.php new file mode 100644 index 0000000..80917f5 --- /dev/null +++ b/backend/app/Models/ProductSheet.php @@ -0,0 +1,139 @@ + 'decimal:2', + 'sale_price' => 'decimal:2', + 'markup_used' => 'decimal:4', + 'contribution_margin' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + /** + * Relacionamento com usuário + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Relacionamento com configuração de negócio + */ + public function businessSetting(): BelongsTo + { + return $this->belongsTo(BusinessSetting::class); + } + + /** + * Itens/componentes de custo desta ficha técnica + */ + public function items(): HasMany + { + return $this->hasMany(ProductSheetItem::class)->orderBy('sort_order'); + } + + /** + * Recalcula o CMV total baseado nos itens + * + * @return float + */ + public function recalculateCmv(): float + { + $this->cmv_total = $this->items()->sum('unit_cost'); + $this->save(); + + return $this->cmv_total; + } + + /** + * Calcula e atualiza o preço de venda usando o Markup da configuração + * + * @param BusinessSetting|null $businessSetting + * @return float + */ + public function calculateSalePrice(?BusinessSetting $businessSetting = null): float + { + $setting = $businessSetting ?? $this->businessSetting; + + if (!$setting) { + return 0; + } + + $markup = $setting->markup_factor ?? $setting->calculateMarkup(); + + if ($markup <= 0) { + return 0; + } + + $this->markup_used = $markup; + $this->sale_price = round($this->cmv_total * $markup, 2); + $this->contribution_margin = $this->sale_price - $this->cmv_total; + $this->save(); + + return $this->sale_price; + } + + /** + * Retorna a margem de contribuição percentual + * + * @return float + */ + public function getContributionMarginPercentAttribute(): float + { + if ($this->sale_price <= 0) { + return 0; + } + + return round(($this->contribution_margin / $this->sale_price) * 100, 2); + } + + /** + * Scope para buscar fichas do usuário + */ + public function scopeOfUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope para buscar apenas fichas ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope para buscar por categoria + */ + public function scopeByCategory($query, $category) + { + return $query->where('category', $category); + } +} diff --git a/backend/app/Models/ProductSheetItem.php b/backend/app/Models/ProductSheetItem.php new file mode 100644 index 0000000..02165d9 --- /dev/null +++ b/backend/app/Models/ProductSheetItem.php @@ -0,0 +1,104 @@ + 'decimal:2', + 'quantity' => 'decimal:4', + 'unit_cost' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + /** + * Tipos de componentes disponíveis + */ + const TYPE_PRODUCT_COST = 'product_cost'; + const TYPE_PACKAGING = 'packaging'; + const TYPE_LABEL = 'label'; + const TYPE_SHIPPING = 'shipping'; + const TYPE_HANDLING = 'handling'; + const TYPE_OTHER = 'other'; + + /** + * Lista de tipos para validação + */ + public static function getTypes(): array + { + return [ + self::TYPE_PRODUCT_COST => 'Custo do Produto', + self::TYPE_PACKAGING => 'Embalagem', + self::TYPE_LABEL => 'Etiqueta', + self::TYPE_SHIPPING => 'Frete', + self::TYPE_HANDLING => 'Manuseio', + self::TYPE_OTHER => 'Outro', + ]; + } + + /** + * Relacionamento com ficha técnica + */ + public function productSheet(): BelongsTo + { + return $this->belongsTo(ProductSheet::class); + } + + /** + * Calcula o custo unitário + * + * @return float + */ + public function calculateUnitCost(): float + { + if ($this->quantity <= 0) { + return (float) $this->amount; + } + + return round($this->amount / $this->quantity, 2); + } + + /** + * Boot do modelo para recalcular automaticamente + */ + protected static function boot() + { + parent::boot(); + + // Antes de salvar, calcular o custo unitário + static::saving(function ($item) { + $item->unit_cost = $item->calculateUnitCost(); + }); + + // Após salvar, recalcular o CMV da ficha técnica + static::saved(function ($item) { + if ($item->productSheet) { + $item->productSheet->recalculateCmv(); + } + }); + + // Após deletar, recalcular o CMV da ficha técnica + static::deleted(function ($item) { + if ($item->productSheet) { + $item->productSheet->recalculateCmv(); + } + }); + } +} diff --git a/backend/database/migrations/2025_12_14_000001_create_business_settings_table.php b/backend/database/migrations/2025_12_14_000001_create_business_settings_table.php new file mode 100644 index 0000000..cf10832 --- /dev/null +++ b/backend/database/migrations/2025_12_14_000001_create_business_settings_table.php @@ -0,0 +1,59 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + + // Moeda padrão para esta configuração + $table->string('currency', 3)->default('EUR'); + + // Nome da configuração (para múltiplos negócios) + $table->string('name')->default('Principal'); + + // Receita média mensal (para calcular % despesas fixas) + $table->decimal('monthly_revenue', 15, 2)->default(0); + + // Despesas fixas mensais (salário, aluguel, internet, etc) + $table->decimal('fixed_expenses', 15, 2)->default(0); + + // Custos variáveis (sem CMV) - em percentual + $table->decimal('tax_rate', 5, 2)->default(0); // Impostos % + $table->decimal('sales_commission', 5, 2)->default(0); // Comissão de venda % + $table->decimal('card_fee', 5, 2)->default(0); // Taxa de cartão % + $table->decimal('other_variable_costs', 5, 2)->default(0); // Outros custos variáveis % + + // Investimento e Lucro desejados - em percentual + $table->decimal('investment_rate', 5, 2)->default(0); // Investimento na empresa % + $table->decimal('profit_margin', 5, 2)->default(0); // Lucro desejado % + + // Markup calculado (cache) + $table->decimal('markup_factor', 8, 4)->nullable(); + + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Índices + $table->index(['user_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('business_settings'); + } +}; diff --git a/backend/database/migrations/2025_12_14_000002_create_product_sheets_table.php b/backend/database/migrations/2025_12_14_000002_create_product_sheets_table.php new file mode 100644 index 0000000..64bc979 --- /dev/null +++ b/backend/database/migrations/2025_12_14_000002_create_product_sheets_table.php @@ -0,0 +1,57 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('business_setting_id')->nullable()->constrained('business_settings')->onDelete('set null'); + + // Informações do produto + $table->string('name'); // Nome do produto + $table->string('sku')->nullable(); // Código/SKU + $table->text('description')->nullable(); + $table->string('category')->nullable(); // Categoria do produto + + // Moeda + $table->string('currency', 3)->default('EUR'); + + // CMV Total calculado (soma dos componentes) + $table->decimal('cmv_total', 15, 2)->default(0); + + // Preço de venda calculado (CMV × Markup) + $table->decimal('sale_price', 15, 2)->nullable(); + + // Markup usado no cálculo (snapshot) + $table->decimal('markup_used', 8, 4)->nullable(); + + // Margem de contribuição calculada + $table->decimal('contribution_margin', 15, 2)->nullable(); + + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Índices + $table->index(['user_id', 'is_active']); + $table->index(['user_id', 'category']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_sheets'); + } +}; diff --git a/backend/database/migrations/2025_12_14_000003_create_product_sheet_items_table.php b/backend/database/migrations/2025_12_14_000003_create_product_sheet_items_table.php new file mode 100644 index 0000000..ce2c9b7 --- /dev/null +++ b/backend/database/migrations/2025_12_14_000003_create_product_sheet_items_table.php @@ -0,0 +1,61 @@ +id(); + $table->foreignId('product_sheet_id')->constrained()->onDelete('cascade'); + + // Descrição do componente de custo + $table->string('name'); // Ex: "Custo do produto", "Embalagem", "Etiqueta", "Frete" + + // Tipo de componente + $table->enum('type', [ + 'product_cost', // Custo do produto em si + 'packaging', // Embalagem + 'label', // Etiqueta + 'shipping', // Frete de compra + 'handling', // Manuseio + 'other' // Outros + ])->default('other'); + + // Valor do componente + $table->decimal('amount', 15, 2)->default(0); + + // Quantidade (para cálculo unitário) + $table->decimal('quantity', 10, 4)->default(1); + + // Unidade de medida + $table->string('unit')->nullable(); // un, kg, m, etc + + // Valor unitário calculado + $table->decimal('unit_cost', 15, 2)->default(0); + + // Ordem de exibição + $table->integer('sort_order')->default(0); + + $table->timestamps(); + + // Índices + $table->index(['product_sheet_id', 'sort_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_sheet_items'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index cd5f2d2..8b5ba5c 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -13,6 +13,8 @@ use App\Http\Controllers\Api\TransferDetectionController; use App\Http\Controllers\Api\DashboardController; use App\Http\Controllers\Api\RecurringTemplateController; +use App\Http\Controllers\Api\BusinessSettingController; +use App\Http\Controllers\Api\ProductSheetController; // Public routes with rate limiting Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register'); @@ -189,5 +191,25 @@ Route::post('recurring-instances/{recurringInstance}/skip', [RecurringTemplateController::class, 'skip']); Route::post('recurring-instances/{recurringInstance}/cancel', [RecurringTemplateController::class, 'cancel']); Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']); + + // ============================================ + // Business - Precificação de Produtos + // ============================================ + + // Configurações de Negócio (Markup) + Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']); + Route::apiResource('business-settings', BusinessSettingController::class); + Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']); + Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']); + + // Fichas Técnicas de Produtos (CMV) + Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']); + Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']); + Route::apiResource('product-sheets', ProductSheetController::class); + Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']); + Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']); + Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']); + Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']); + Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7e29fc4..58b5cb2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,7 @@ import ImportTransactions from './pages/ImportTransactions'; import TransferDetection from './pages/TransferDetection'; import RefundDetection from './pages/RefundDetection'; import RecurringTransactions from './pages/RecurringTransactions'; +import Business from './pages/Business'; function App() { return ( @@ -124,6 +125,16 @@ function App() { } /> + + + + + + } + /> } /> diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index fea904b..e1b5eeb 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -63,6 +63,7 @@ const Layout = ({ children }) => { ] }, { type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') }, + { type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') }, { type: 'group', id: 'settings', diff --git a/frontend/src/components/business/BusinessSettingModal.jsx b/frontend/src/components/business/BusinessSettingModal.jsx new file mode 100644 index 0000000..40a45e3 --- /dev/null +++ b/frontend/src/components/business/BusinessSettingModal.jsx @@ -0,0 +1,412 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { businessSettingService } from '../../services/api'; + +const BusinessSettingModal = ({ setting, onSave, onClose }) => { + const { t } = useTranslation(); + const isEditing = !!setting; + + const [formData, setFormData] = useState({ + name: '', + currency: 'EUR', + monthly_revenue: '', + fixed_expenses: '', + tax_rate: '', + sales_commission: '', + card_fee: '', + other_variable_costs: '', + investment_rate: '', + profit_margin: '', + is_active: true, + }); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + + useEffect(() => { + if (setting) { + setFormData({ + name: setting.name || '', + currency: setting.currency || 'EUR', + monthly_revenue: setting.monthly_revenue || '', + fixed_expenses: setting.fixed_expenses || '', + tax_rate: setting.tax_rate || '', + sales_commission: setting.sales_commission || '', + card_fee: setting.card_fee || '', + other_variable_costs: setting.other_variable_costs || '', + investment_rate: setting.investment_rate || '', + profit_margin: setting.profit_margin || '', + is_active: setting.is_active ?? true, + }); + } + }, [setting]); + + // Calcular preview do markup em tempo real + useEffect(() => { + const monthlyRevenue = parseFloat(formData.monthly_revenue) || 0; + const fixedExpenses = parseFloat(formData.fixed_expenses) || 0; + const taxRate = parseFloat(formData.tax_rate) || 0; + const salesCommission = parseFloat(formData.sales_commission) || 0; + const cardFee = parseFloat(formData.card_fee) || 0; + const otherVariableCosts = parseFloat(formData.other_variable_costs) || 0; + const investmentRate = parseFloat(formData.investment_rate) || 0; + const profitMargin = parseFloat(formData.profit_margin) || 0; + + // Calcular % despesas fixas + const fixedExpensesRate = monthlyRevenue > 0 ? (fixedExpenses / monthlyRevenue) * 100 : 0; + + // Total custos variáveis + const totalVariableCosts = taxRate + salesCommission + cardFee + otherVariableCosts; + + // Total deduções (em decimal) + const totalDeductions = (fixedExpensesRate + totalVariableCosts + investmentRate + profitMargin) / 100; + + // Markup + let markup = 0; + if (totalDeductions < 1) { + markup = 1 / (1 - totalDeductions); + } + + setPreview({ + fixedExpensesRate: fixedExpensesRate.toFixed(2), + totalVariableCosts: totalVariableCosts.toFixed(2), + totalDeductions: (totalDeductions * 100).toFixed(2), + markup: markup.toFixed(4), + isValid: totalDeductions < 1, + }); + }, [formData]); + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(null); + + try { + const dataToSend = { + ...formData, + monthly_revenue: parseFloat(formData.monthly_revenue) || 0, + fixed_expenses: parseFloat(formData.fixed_expenses) || 0, + tax_rate: parseFloat(formData.tax_rate) || 0, + sales_commission: parseFloat(formData.sales_commission) || 0, + card_fee: parseFloat(formData.card_fee) || 0, + other_variable_costs: parseFloat(formData.other_variable_costs) || 0, + investment_rate: parseFloat(formData.investment_rate) || 0, + profit_margin: parseFloat(formData.profit_margin) || 0, + }; + + let result; + if (isEditing) { + result = await businessSettingService.update(setting.id, dataToSend); + } else { + result = await businessSettingService.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']; + + return ( +
+
+
+
+
+ + {isEditing ? t('business.settings.edit') : t('business.settings.add')} +
+ +
+ +
+
+ {error && ( +
{typeof error === 'string' ? error : JSON.stringify(error)}
+ )} + +
+ {/* Nome e Moeda */} +
+ + +
+
+ + +
+ + {/* Receita e Despesas Fixas */} +
+
+
+ + {t('business.settings.revenueAndExpenses')} +
+
+
+ + + {t('business.settings.monthlyRevenueHelp')} +
+
+ + + {t('business.settings.fixedExpensesHelp')} +
+ + {/* Custos Variáveis */} +
+
+
+ + {t('business.settings.variableCosts')} (%) +
+
+
+ +
+ + % +
+
+
+ +
+ + % +
+
+
+ +
+ + % +
+
+
+ +
+ + % +
+
+ + {/* Investimento e Lucro */} +
+
+
+ + {t('business.settings.investmentAndProfit')} (%) +
+
+
+ +
+ + % +
+ {t('business.settings.investmentRateHelp')} +
+
+ +
+ + % +
+ {t('business.settings.profitMarginHelp')} +
+ + {/* Preview do Markup */} + {preview && ( +
+
+
+
+ + {t('business.settings.markupPreview')} +
+ +
+
+ {t('business.settings.fixedExpensesRate')}: + {preview.fixedExpensesRate}% +
+
+ {t('business.settings.totalVariableCosts')}: + {preview.totalVariableCosts}% +
+
+ {t('business.settings.totalDeductions')}: + {preview.totalDeductions}% +
+
+ + {preview.isValid ? ( +
+ {t('business.settings.markupFactor')}: +

{preview.markup}

+
+ ) : ( +
+ + {t('business.settings.invalidMarkup')} +
+ )} +
+
+ )} + + {/* Status */} + {isEditing && ( +
+
+ + +
+
+ )} +
+
+ +
+ + +
+
+
+
+
+ ); +}; + +export default BusinessSettingModal; diff --git a/frontend/src/components/business/BusinessSettingsTab.jsx b/frontend/src/components/business/BusinessSettingsTab.jsx new file mode 100644 index 0000000..d50e5cf --- /dev/null +++ b/frontend/src/components/business/BusinessSettingsTab.jsx @@ -0,0 +1,211 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { businessSettingService } from '../../services/api'; +import useFormatters from '../../hooks/useFormatters'; +import BusinessSettingModal from './BusinessSettingModal'; + +const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => { + const { t } = useTranslation(); + const { currency, percent } = useFormatters(); + + const [showModal, setShowModal] = useState(false); + const [editingSetting, setEditingSetting] = useState(null); + const [deleting, setDeleting] = useState(null); + + const handleCreate = () => { + setEditingSetting(null); + setShowModal(true); + }; + + const handleEdit = (setting) => { + setEditingSetting(setting); + setShowModal(true); + }; + + const handleDelete = async (setting) => { + if (!window.confirm(t('business.confirmDelete'))) return; + + setDeleting(setting.id); + try { + await businessSettingService.delete(setting.id); + onDeleted(setting.id); + } catch (err) { + alert(err.response?.data?.message || t('common.error')); + } finally { + setDeleting(null); + } + }; + + const handleSave = (savedSetting) => { + if (editingSetting) { + onUpdated(savedSetting); + } else { + onCreated(savedSetting); + } + setShowModal(false); + }; + + return ( + <> + {/* Header */} +
+
+
{t('business.settings.title')}
+

{t('business.settings.description')}

+
+ +
+ + {/* Empty State */} + {settings.length === 0 ? ( +
+
+ +
{t('business.settings.empty')}
+

{t('business.settings.emptyDescription')}

+ +
+
+ ) : ( + /* Settings Cards */ +
+ {settings.map(setting => ( +
+
+
+
+
+ {setting.name} + {!setting.is_active && ( + {t('common.inactive')} + )} +
+ {setting.currency} +
+
+ +
    +
  • + +
  • +

  • +
  • + +
  • +
+
+
+ +
+ {/* Markup Factor - Destaque */} +
+ {t('business.settings.markupFactor')} +

{setting.markup_factor?.toFixed(2) || '0.00'}

+
+ + {/* Breakdown */} +
+
+
+ {t('business.settings.monthlyRevenue')} + {currency(setting.monthly_revenue, setting.currency)} +
+
+
+
+ {t('business.settings.fixedExpenses')} + {currency(setting.fixed_expenses, setting.currency)} +
+
+
+
+ {t('business.settings.fixedExpensesRate')} + {setting.fixed_expenses_rate?.toFixed(2)}% +
+
+
+
+ {t('business.settings.totalVariableCosts')} + {setting.total_variable_costs?.toFixed(2)}% +
+
+
+ + {/* Variable Costs Detail */} +
+ {t('business.settings.variableCostsBreakdown')} +
+ + {t('business.settings.taxRate')}: {setting.tax_rate}% + + + {t('business.settings.salesCommission')}: {setting.sales_commission}% + + + {t('business.settings.cardFee')}: {setting.card_fee}% + + {setting.other_variable_costs > 0 && ( + + {t('business.settings.otherVariableCosts')}: {setting.other_variable_costs}% + + )} +
+
+ + {/* Investment & Profit */} +
+
+
+ {t('business.settings.investmentRate')} + {setting.investment_rate}% +
+
+ {t('business.settings.profitMargin')} + {setting.profit_margin}% +
+
+
+
+
+
+ ))} +
+ )} + + {/* Modal */} + {showModal && ( + setShowModal(false)} + /> + )} + + ); +}; + +export default BusinessSettingsTab; diff --git a/frontend/src/components/business/PriceCalculatorTab.jsx b/frontend/src/components/business/PriceCalculatorTab.jsx new file mode 100644 index 0000000..d9cc6b9 --- /dev/null +++ b/frontend/src/components/business/PriceCalculatorTab.jsx @@ -0,0 +1,315 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { businessSettingService } from '../../services/api'; +import useFormatters from '../../hooks/useFormatters'; + +const PriceCalculatorTab = ({ settings, sheets }) => { + const { t } = useTranslation(); + const { currency } = useFormatters(); + + const [selectedSetting, setSelectedSetting] = useState(null); + const [cmvInput, setCmvInput] = useState(''); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + // Selecionar primeira configuração ativa por padrão + useEffect(() => { + const activeSetting = settings.find(s => s.is_active); + if (activeSetting) { + setSelectedSetting(activeSetting); + } + }, [settings]); + + const handleCalculate = async () => { + if (!selectedSetting || !cmvInput) return; + + setLoading(true); + try { + const result = await businessSettingService.simulatePrice(selectedSetting.id, parseFloat(cmvInput)); + setResult(result); + } catch (err) { + alert(err.response?.data?.message || t('common.error')); + } finally { + setLoading(false); + } + }; + + const handleSettingChange = (e) => { + const setting = settings.find(s => s.id === parseInt(e.target.value)); + setSelectedSetting(setting); + setResult(null); + }; + + // Cálculo local em tempo real + const localCalculation = () => { + if (!selectedSetting || !cmvInput) return null; + + const cmv = parseFloat(cmvInput) || 0; + const markup = selectedSetting.markup_factor || 0; + const salePrice = cmv * markup; + const contributionMargin = salePrice - cmv; + const contributionMarginPercent = salePrice > 0 ? (contributionMargin / salePrice) * 100 : 0; + + return { + cmv, + markup, + salePrice, + contributionMargin, + contributionMarginPercent, + }; + }; + + const localResult = localCalculation(); + + return ( + <> + {/* Header */} +
+
{t('business.calculator.title')}
+

{t('business.calculator.description')}

+
+ + {settings.length === 0 ? ( +
+
+ +
{t('business.calculator.noSettings')}
+

{t('business.calculator.createSettingFirst')}

+
+
+ ) : ( +
+ {/* Calculadora */} +
+
+
+
+ + {t('business.calculator.simulate')} +
+
+
+ {/* Seleção de Configuração */} +
+ + +
+ + {/* Input CMV */} +
+ +
+ + {selectedSetting?.currency || 'EUR'} + + { + setCmvInput(e.target.value); + setResult(null); + }} + placeholder="0.00" + /> +
+ {t('business.calculator.cmvHelp')} +
+ + {/* Resultado em Tempo Real */} + {localResult && localResult.cmv > 0 && ( +
+
+
+ CMV + + {currency(localResult.cmv, selectedSetting?.currency)} + +
+
+ × Markup + {localResult.markup.toFixed(2)} +
+
+
+
+
+ {t('business.calculator.salePrice')} +

+ {currency(localResult.salePrice, selectedSetting?.currency)} +

+
+
+ {t('business.calculator.contributionMargin')} + + {currency(localResult.contributionMargin, selectedSetting?.currency)} + +
+
+ {t('business.calculator.marginPercent')} + + {localResult.contributionMarginPercent.toFixed(2)}% + +
+
+
+ )} + + {/* Fórmula */} +
+ {t('business.calculator.formula')} + + {t('business.calculator.salePrice')} = CMV × Markup + +
+ + Markup = 1 / (1 - ({t('business.settings.fixedExpensesRate')} + {t('business.settings.variableCosts')} + {t('business.settings.investmentRate')} + {t('business.settings.profitMargin')})) + +
+
+
+
+ + {/* Breakdown da Configuração Selecionada */} +
+ {selectedSetting && ( +
+
+
+ + {t('business.calculator.breakdown')} +
+
+
+ {/* Markup Factor */} +
+ {t('business.settings.markupFactor')} +

{selectedSetting.markup_factor?.toFixed(4)}

+
+ + {/* Detalhes */} +
+
+ {t('business.settings.monthlyRevenue')} + {currency(selectedSetting.monthly_revenue, selectedSetting.currency)} +
+
+ {t('business.settings.fixedExpenses')} + {currency(selectedSetting.fixed_expenses, selectedSetting.currency)} +
+
+ {t('business.settings.fixedExpensesRate')} + {selectedSetting.fixed_expenses_rate?.toFixed(2)}% +
+
+ +
+ {t('business.settings.variableCosts')} +
+ {t('business.settings.taxRate')} + {selectedSetting.tax_rate}% +
+
+ {t('business.settings.salesCommission')} + {selectedSetting.sales_commission}% +
+
+ {t('business.settings.cardFee')} + {selectedSetting.card_fee}% +
+ {selectedSetting.other_variable_costs > 0 && ( +
+ {t('business.settings.otherVariableCosts')} + {selectedSetting.other_variable_costs}% +
+ )} +
+ {t('business.settings.totalVariableCosts')} + {selectedSetting.total_variable_costs?.toFixed(2)}% +
+
+ +
+
+ {t('business.settings.investmentRate')} + {selectedSetting.investment_rate}% +
+
+ {t('business.settings.profitMargin')} + {selectedSetting.profit_margin}% +
+
+ + {/* Total Deduções */} +
+
+ {t('business.settings.totalDeductions')} + + {(selectedSetting.fixed_expenses_rate + selectedSetting.total_variable_costs + selectedSetting.investment_rate + selectedSetting.profit_margin).toFixed(2)}% + +
+
+
+
+ )} +
+ + {/* Tabela de Preços Rápidos */} + {selectedSetting && sheets.length > 0 && ( +
+
+
+
+ + {t('business.calculator.quickPrices')} +
+
+
+
+ + + + + + + + + + + {sheets.filter(s => s.is_active && s.currency === selectedSetting.currency).slice(0, 10).map(sheet => { + const salePrice = sheet.cmv_total * selectedSetting.markup_factor; + const margin = salePrice - sheet.cmv_total; + return ( + + + + + + + ); + })} + +
{t('business.products.name')}CMV{t('business.calculator.salePrice')}{t('business.calculator.contributionMargin')}
{sheet.name}{currency(sheet.cmv_total, sheet.currency)}{currency(salePrice, sheet.currency)}{currency(margin, sheet.currency)}
+
+
+
+
+ )} +
+ )} + + ); +}; + +export default PriceCalculatorTab; diff --git a/frontend/src/components/business/ProductSheetModal.jsx b/frontend/src/components/business/ProductSheetModal.jsx new file mode 100644 index 0000000..88c1ba5 --- /dev/null +++ b/frontend/src/components/business/ProductSheetModal.jsx @@ -0,0 +1,450 @@ +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, + }); + + const [items, setItems] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + 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, + }); + setItems(sheet.items?.map(item => ({ + id: item.id, + name: item.name, + type: item.type, + amount: item.amount, + quantity: item.quantity || 1, + unit: item.unit || '', + })) || []); + } + }, [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, + }); + + // 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 ( +
+
+
+
+
+ + {isEditing ? t('business.products.edit') : t('business.products.add')} +
+ +
+ +
+
+ {error && ( +
{typeof error === 'string' ? error : JSON.stringify(error)}
+ )} + +
+ {/* Info básica */} +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ +