feat(business): add Business section with Markup pricing v1.28.0

- Add business_settings table for Markup configuration
- Add product_sheets table for product technical sheets (CMV)
- Add product_sheet_items table for cost components
- Create BusinessSetting model with calculateMarkup() method
- Create ProductSheet model with recalculateCmv() method
- Create ProductSheetItem model for cost breakdown
- Add BusinessSettingController with CRUD + simulate-price endpoint
- Add ProductSheetController with CRUD + items management + duplicate
- Add Business page with 3 tabs (Settings, Products, Calculator)
- Add BusinessSettingsTab component with markup cards
- Add ProductSheetsTab component with product grid
- Add PriceCalculatorTab component with interactive simulator
- Add i18n translations in ES, PT-BR, EN
- Multi-currency support (EUR, BRL, USD)
This commit is contained in:
CnxiFly Dev 2025-12-14 07:44:18 +01:00
parent 9dc9f1a0a1
commit 84d9d7d187
23 changed files with 3548 additions and 1 deletions

View File

@ -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

View File

@ -1 +1 @@
1.27.6
1.28.0

View File

@ -0,0 +1,215 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BusinessSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class BusinessSettingController extends Controller
{
/**
* Lista todas as configurações de negócio do usuário
*/
public function index(Request $request): JsonResponse
{
$userId = $request->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,
]));
}
}

View File

@ -0,0 +1,398 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ProductSheet;
use App\Models\ProductSheetItem;
use App\Models\BusinessSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class ProductSheetController extends Controller
{
/**
* Lista todas as fichas técnicas do usuário
*/
public function index(Request $request): JsonResponse
{
$userId = $request->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);
}
}
}

View File

@ -0,0 +1,180 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BusinessSetting extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'currency',
'name',
'monthly_revenue',
'fixed_expenses',
'tax_rate',
'sales_commission',
'card_fee',
'other_variable_costs',
'investment_rate',
'profit_margin',
'markup_factor',
'is_active',
];
protected $casts = [
'monthly_revenue' => '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);
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductSheet extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'business_setting_id',
'name',
'sku',
'description',
'category',
'currency',
'cmv_total',
'sale_price',
'markup_used',
'contribution_margin',
'is_active',
];
protected $casts = [
'cmv_total' => '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);
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductSheetItem extends Model
{
use HasFactory;
protected $fillable = [
'product_sheet_id',
'name',
'type',
'amount',
'quantity',
'unit',
'unit_cost',
'sort_order',
];
protected $casts = [
'amount' => '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();
}
});
}
}

View File

@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Configurações da empresa para cálculo de Markup
*/
public function up(): void
{
Schema::create('business_settings', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Fichas Técnicas de Produtos - CMV (Custo da Mercadoria Vendida)
*/
public function up(): void
{
Schema::create('product_sheets', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Componentes de custo de uma Ficha Técnica (itens que compõem o CMV)
*/
public function up(): void
{
Schema::create('product_sheet_items', function (Blueprint $table) {
$table->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');
}
};

View File

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

View File

@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/business"
element={
<ProtectedRoute>
<Layout>
<Business />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
<CookieConsent />

View File

@ -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',

View File

@ -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 (
<div className="modal show d-block" style={{ background: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div className="modal-content" style={{ background: '#1e293b', border: 'none' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className="bi bi-gear me-2"></i>
{isEditing ? t('business.settings.edit') : t('business.settings.add')}
</h5>
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div className="alert alert-danger">{typeof error === 'string' ? error : JSON.stringify(error)}</div>
)}
<div className="row g-3">
{/* Nome e Moeda */}
<div className="col-md-8">
<label className="form-label text-slate-400">{t('business.settings.name')}</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t('business.settings.namePlaceholder')}
required
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('common.currency')}</label>
<select
className="form-select bg-dark text-white border-secondary"
name="currency"
value={formData.currency}
onChange={handleChange}
required
>
{currencies.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* Receita e Despesas Fixas */}
<div className="col-12">
<hr className="border-secondary my-2" />
<h6 className="text-slate-400 small mb-3">
<i className="bi bi-graph-up me-2"></i>
{t('business.settings.revenueAndExpenses')}
</h6>
</div>
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.settings.monthlyRevenue')}</label>
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="monthly_revenue"
value={formData.monthly_revenue}
onChange={handleChange}
placeholder="40000.00"
required
/>
<small className="text-slate-500">{t('business.settings.monthlyRevenueHelp')}</small>
</div>
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.settings.fixedExpenses')}</label>
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="fixed_expenses"
value={formData.fixed_expenses}
onChange={handleChange}
placeholder="12000.00"
required
/>
<small className="text-slate-500">{t('business.settings.fixedExpensesHelp')}</small>
</div>
{/* Custos Variáveis */}
<div className="col-12">
<hr className="border-secondary my-2" />
<h6 className="text-slate-400 small mb-3">
<i className="bi bi-percent me-2"></i>
{t('business.settings.variableCosts')} (%)
</h6>
</div>
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.settings.taxRate')}</label>
<div className="input-group">
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="tax_rate"
value={formData.tax_rate}
onChange={handleChange}
placeholder="9"
required
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
</div>
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.settings.salesCommission')}</label>
<div className="input-group">
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="sales_commission"
value={formData.sales_commission}
onChange={handleChange}
placeholder="5"
required
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
</div>
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.settings.cardFee')}</label>
<div className="input-group">
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="card_fee"
value={formData.card_fee}
onChange={handleChange}
placeholder="3"
required
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
</div>
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.settings.otherVariableCosts')}</label>
<div className="input-group">
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="other_variable_costs"
value={formData.other_variable_costs}
onChange={handleChange}
placeholder="0"
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
</div>
{/* Investimento e Lucro */}
<div className="col-12">
<hr className="border-secondary my-2" />
<h6 className="text-slate-400 small mb-3">
<i className="bi bi-piggy-bank me-2"></i>
{t('business.settings.investmentAndProfit')} (%)
</h6>
</div>
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.settings.investmentRate')}</label>
<div className="input-group">
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="investment_rate"
value={formData.investment_rate}
onChange={handleChange}
placeholder="10"
required
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
<small className="text-slate-500">{t('business.settings.investmentRateHelp')}</small>
</div>
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.settings.profitMargin')}</label>
<div className="input-group">
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="profit_margin"
value={formData.profit_margin}
onChange={handleChange}
placeholder="15"
required
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
<small className="text-slate-500">{t('business.settings.profitMarginHelp')}</small>
</div>
{/* Preview do Markup */}
{preview && (
<div className="col-12">
<hr className="border-secondary my-2" />
<div className={`p-3 rounded ${preview.isValid ? '' : 'border border-danger'}`} style={{ background: 'rgba(59, 130, 246, 0.1)' }}>
<h6 className="text-slate-400 small mb-3">
<i className="bi bi-calculator me-2"></i>
{t('business.settings.markupPreview')}
</h6>
<div className="row g-2 small mb-3">
<div className="col-6 col-md-3">
<span className="text-slate-500">{t('business.settings.fixedExpensesRate')}:</span>
<span className="text-white ms-2">{preview.fixedExpensesRate}%</span>
</div>
<div className="col-6 col-md-3">
<span className="text-slate-500">{t('business.settings.totalVariableCosts')}:</span>
<span className="text-white ms-2">{preview.totalVariableCosts}%</span>
</div>
<div className="col-6 col-md-3">
<span className="text-slate-500">{t('business.settings.totalDeductions')}:</span>
<span className="text-warning ms-2">{preview.totalDeductions}%</span>
</div>
</div>
{preview.isValid ? (
<div className="text-center">
<span className="text-slate-400">{t('business.settings.markupFactor')}:</span>
<h3 className="text-primary mb-0">{preview.markup}</h3>
</div>
) : (
<div className="text-center text-danger">
<i className="bi bi-exclamation-triangle me-2"></i>
{t('business.settings.invalidMarkup')}
</div>
)}
</div>
</div>
)}
{/* Status */}
{isEditing && (
<div className="col-12">
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
/>
<label className="form-check-label text-slate-400" htmlFor="is_active">
{t('business.settings.isActive')}
</label>
</div>
</div>
)}
</div>
</div>
<div className="modal-footer border-0">
<button type="button" className="btn btn-secondary" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={saving || (preview && !preview.isValid)}
>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
{t('common.save')}
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default BusinessSettingModal;

View File

@ -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 */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 className="text-white mb-1">{t('business.settings.title')}</h5>
<p className="text-slate-400 small mb-0">{t('business.settings.description')}</p>
</div>
<button className="btn btn-primary" onClick={handleCreate}>
<i className="bi bi-plus-lg me-2"></i>
{t('business.settings.add')}
</button>
</div>
{/* Empty State */}
{settings.length === 0 ? (
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-5">
<i className="bi bi-gear fs-1 text-slate-500 mb-3 d-block"></i>
<h5 className="text-white mb-2">{t('business.settings.empty')}</h5>
<p className="text-slate-400 mb-4">{t('business.settings.emptyDescription')}</p>
<button className="btn btn-primary" onClick={handleCreate}>
<i className="bi bi-plus-lg me-2"></i>
{t('business.settings.createFirst')}
</button>
</div>
</div>
) : (
/* Settings Cards */
<div className="row g-4">
{settings.map(setting => (
<div key={setting.id} className="col-12 col-lg-6">
<div
className="card border-0 h-100"
style={{
background: setting.is_active ? '#1e293b' : '#0f172a',
opacity: setting.is_active ? 1 : 0.7,
}}
>
<div className="card-header border-0 d-flex justify-content-between align-items-center py-3" style={{ background: 'transparent' }}>
<div>
<h6 className="text-white mb-0">
{setting.name}
{!setting.is_active && (
<span className="badge bg-secondary ms-2">{t('common.inactive')}</span>
)}
</h6>
<small className="text-slate-500">{setting.currency}</small>
</div>
<div className="dropdown">
<button className="btn btn-sm btn-outline-secondary border-0" data-bs-toggle="dropdown">
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end" style={{ background: '#1e293b' }}>
<li>
<button className="dropdown-item text-white" onClick={() => handleEdit(setting)}>
<i className="bi bi-pencil me-2"></i>
{t('common.edit')}
</button>
</li>
<li><hr className="dropdown-divider" style={{ borderColor: 'rgba(255,255,255,0.1)' }} /></li>
<li>
<button
className="dropdown-item text-danger"
onClick={() => handleDelete(setting)}
disabled={deleting === setting.id}
>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</div>
<div className="card-body py-3">
{/* Markup Factor - Destaque */}
<div className="text-center mb-4 p-3 rounded" style={{ background: 'rgba(59, 130, 246, 0.1)' }}>
<small className="text-slate-400 d-block mb-1">{t('business.settings.markupFactor')}</small>
<h2 className="text-primary mb-0">{setting.markup_factor?.toFixed(2) || '0.00'}</h2>
</div>
{/* Breakdown */}
<div className="row g-3 small">
<div className="col-6">
<div className="p-2 rounded" style={{ background: 'rgba(255,255,255,0.05)' }}>
<small className="text-slate-500 d-block">{t('business.settings.monthlyRevenue')}</small>
<span className="text-white">{currency(setting.monthly_revenue, setting.currency)}</span>
</div>
</div>
<div className="col-6">
<div className="p-2 rounded" style={{ background: 'rgba(255,255,255,0.05)' }}>
<small className="text-slate-500 d-block">{t('business.settings.fixedExpenses')}</small>
<span className="text-white">{currency(setting.fixed_expenses, setting.currency)}</span>
</div>
</div>
<div className="col-6">
<div className="p-2 rounded" style={{ background: 'rgba(255,255,255,0.05)' }}>
<small className="text-slate-500 d-block">{t('business.settings.fixedExpensesRate')}</small>
<span className="text-warning">{setting.fixed_expenses_rate?.toFixed(2)}%</span>
</div>
</div>
<div className="col-6">
<div className="p-2 rounded" style={{ background: 'rgba(255,255,255,0.05)' }}>
<small className="text-slate-500 d-block">{t('business.settings.totalVariableCosts')}</small>
<span className="text-warning">{setting.total_variable_costs?.toFixed(2)}%</span>
</div>
</div>
</div>
{/* Variable Costs Detail */}
<div className="mt-3 pt-3" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<small className="text-slate-500 d-block mb-2">{t('business.settings.variableCostsBreakdown')}</small>
<div className="d-flex flex-wrap gap-2">
<span className="badge bg-secondary">
{t('business.settings.taxRate')}: {setting.tax_rate}%
</span>
<span className="badge bg-secondary">
{t('business.settings.salesCommission')}: {setting.sales_commission}%
</span>
<span className="badge bg-secondary">
{t('business.settings.cardFee')}: {setting.card_fee}%
</span>
{setting.other_variable_costs > 0 && (
<span className="badge bg-secondary">
{t('business.settings.otherVariableCosts')}: {setting.other_variable_costs}%
</span>
)}
</div>
</div>
{/* Investment & Profit */}
<div className="mt-3 pt-3" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div className="d-flex justify-content-between">
<div>
<small className="text-slate-500 d-block">{t('business.settings.investmentRate')}</small>
<span className="text-info">{setting.investment_rate}%</span>
</div>
<div className="text-end">
<small className="text-slate-500 d-block">{t('business.settings.profitMargin')}</small>
<span className="text-success">{setting.profit_margin}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{showModal && (
<BusinessSettingModal
setting={editingSetting}
onSave={handleSave}
onClose={() => setShowModal(false)}
/>
)}
</>
);
};
export default BusinessSettingsTab;

View File

@ -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 */}
<div className="mb-4">
<h5 className="text-white mb-1">{t('business.calculator.title')}</h5>
<p className="text-slate-400 small mb-0">{t('business.calculator.description')}</p>
</div>
{settings.length === 0 ? (
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-5">
<i className="bi bi-gear fs-1 text-slate-500 mb-3 d-block"></i>
<h5 className="text-white mb-2">{t('business.calculator.noSettings')}</h5>
<p className="text-slate-400">{t('business.calculator.createSettingFirst')}</p>
</div>
</div>
) : (
<div className="row g-4">
{/* Calculadora */}
<div className="col-lg-6">
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
<div className="card-header border-0" style={{ background: 'transparent' }}>
<h6 className="text-white mb-0">
<i className="bi bi-calculator me-2"></i>
{t('business.calculator.simulate')}
</h6>
</div>
<div className="card-body">
{/* Seleção de Configuração */}
<div className="mb-4">
<label className="form-label text-slate-400">{t('business.calculator.selectSetting')}</label>
<select
className="form-select bg-dark text-white border-secondary"
value={selectedSetting?.id || ''}
onChange={handleSettingChange}
>
{settings.map(s => (
<option key={s.id} value={s.id}>
{s.name} ({s.currency}) - Markup: {s.markup_factor?.toFixed(2)}
</option>
))}
</select>
</div>
{/* Input CMV */}
<div className="mb-4">
<label className="form-label text-slate-400">{t('business.calculator.enterCmv')}</label>
<div className="input-group">
<span className="input-group-text bg-dark text-slate-400 border-secondary">
{selectedSetting?.currency || 'EUR'}
</span>
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
value={cmvInput}
onChange={(e) => {
setCmvInput(e.target.value);
setResult(null);
}}
placeholder="0.00"
/>
</div>
<small className="text-slate-500">{t('business.calculator.cmvHelp')}</small>
</div>
{/* Resultado em Tempo Real */}
{localResult && localResult.cmv > 0 && (
<div className="p-3 rounded mb-4" style={{ background: 'rgba(59, 130, 246, 0.1)' }}>
<div className="row g-3 text-center">
<div className="col-6">
<small className="text-slate-500 d-block">CMV</small>
<span className="text-danger fw-bold">
{currency(localResult.cmv, selectedSetting?.currency)}
</span>
</div>
<div className="col-6">
<small className="text-slate-500 d-block">× Markup</small>
<span className="text-primary fw-bold">{localResult.markup.toFixed(2)}</span>
</div>
<div className="col-12">
<hr className="border-secondary my-2" />
</div>
<div className="col-12">
<small className="text-slate-500 d-block">{t('business.calculator.salePrice')}</small>
<h3 className="text-success mb-0">
{currency(localResult.salePrice, selectedSetting?.currency)}
</h3>
</div>
<div className="col-6">
<small className="text-slate-500 d-block">{t('business.calculator.contributionMargin')}</small>
<span className="text-info">
{currency(localResult.contributionMargin, selectedSetting?.currency)}
</span>
</div>
<div className="col-6">
<small className="text-slate-500 d-block">{t('business.calculator.marginPercent')}</small>
<span className="text-info">
{localResult.contributionMarginPercent.toFixed(2)}%
</span>
</div>
</div>
</div>
)}
{/* Fórmula */}
<div className="p-3 rounded" style={{ background: 'rgba(255,255,255,0.05)' }}>
<small className="text-slate-500 d-block mb-2">{t('business.calculator.formula')}</small>
<code className="text-info">
{t('business.calculator.salePrice')} = CMV × Markup
</code>
<br />
<code className="text-slate-400 small">
Markup = 1 / (1 - ({t('business.settings.fixedExpensesRate')} + {t('business.settings.variableCosts')} + {t('business.settings.investmentRate')} + {t('business.settings.profitMargin')}))
</code>
</div>
</div>
</div>
</div>
{/* Breakdown da Configuração Selecionada */}
<div className="col-lg-6">
{selectedSetting && (
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
<div className="card-header border-0" style={{ background: 'transparent' }}>
<h6 className="text-white mb-0">
<i className="bi bi-pie-chart me-2"></i>
{t('business.calculator.breakdown')}
</h6>
</div>
<div className="card-body">
{/* Markup Factor */}
<div className="text-center mb-4 p-3 rounded" style={{ background: 'rgba(59, 130, 246, 0.1)' }}>
<small className="text-slate-400 d-block mb-1">{t('business.settings.markupFactor')}</small>
<h2 className="text-primary mb-0">{selectedSetting.markup_factor?.toFixed(4)}</h2>
</div>
{/* Detalhes */}
<div className="mb-3">
<div className="d-flex justify-content-between py-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<span className="text-slate-400">{t('business.settings.monthlyRevenue')}</span>
<span className="text-white">{currency(selectedSetting.monthly_revenue, selectedSetting.currency)}</span>
</div>
<div className="d-flex justify-content-between py-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<span className="text-slate-400">{t('business.settings.fixedExpenses')}</span>
<span className="text-white">{currency(selectedSetting.fixed_expenses, selectedSetting.currency)}</span>
</div>
<div className="d-flex justify-content-between py-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<span className="text-slate-400">{t('business.settings.fixedExpensesRate')}</span>
<span className="text-warning">{selectedSetting.fixed_expenses_rate?.toFixed(2)}%</span>
</div>
</div>
<div className="mb-3">
<small className="text-slate-500 d-block mb-2">{t('business.settings.variableCosts')}</small>
<div className="d-flex justify-content-between py-1">
<span className="text-slate-400 small">{t('business.settings.taxRate')}</span>
<span className="text-white small">{selectedSetting.tax_rate}%</span>
</div>
<div className="d-flex justify-content-between py-1">
<span className="text-slate-400 small">{t('business.settings.salesCommission')}</span>
<span className="text-white small">{selectedSetting.sales_commission}%</span>
</div>
<div className="d-flex justify-content-between py-1">
<span className="text-slate-400 small">{t('business.settings.cardFee')}</span>
<span className="text-white small">{selectedSetting.card_fee}%</span>
</div>
{selectedSetting.other_variable_costs > 0 && (
<div className="d-flex justify-content-between py-1">
<span className="text-slate-400 small">{t('business.settings.otherVariableCosts')}</span>
<span className="text-white small">{selectedSetting.other_variable_costs}%</span>
</div>
)}
<div className="d-flex justify-content-between py-2 mt-1" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<span className="text-slate-400">{t('business.settings.totalVariableCosts')}</span>
<span className="text-warning">{selectedSetting.total_variable_costs?.toFixed(2)}%</span>
</div>
</div>
<div className="mb-3">
<div className="d-flex justify-content-between py-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<span className="text-slate-400">{t('business.settings.investmentRate')}</span>
<span className="text-info">{selectedSetting.investment_rate}%</span>
</div>
<div className="d-flex justify-content-between py-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<span className="text-slate-400">{t('business.settings.profitMargin')}</span>
<span className="text-success">{selectedSetting.profit_margin}%</span>
</div>
</div>
{/* Total Deduções */}
<div className="p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)' }}>
<div className="d-flex justify-content-between">
<span className="text-slate-400">{t('business.settings.totalDeductions')}</span>
<span className="text-danger fw-bold">
{(selectedSetting.fixed_expenses_rate + selectedSetting.total_variable_costs + selectedSetting.investment_rate + selectedSetting.profit_margin).toFixed(2)}%
</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Tabela de Preços Rápidos */}
{selectedSetting && sheets.length > 0 && (
<div className="col-12">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-header border-0" style={{ background: 'transparent' }}>
<h6 className="text-white mb-0">
<i className="bi bi-table me-2"></i>
{t('business.calculator.quickPrices')}
</h6>
</div>
<div className="card-body">
<div className="table-responsive">
<table className="table table-dark table-hover mb-0">
<thead>
<tr>
<th className="text-slate-400">{t('business.products.name')}</th>
<th className="text-slate-400 text-end">CMV</th>
<th className="text-slate-400 text-end">{t('business.calculator.salePrice')}</th>
<th className="text-slate-400 text-end">{t('business.calculator.contributionMargin')}</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={sheet.id}>
<td className="text-white">{sheet.name}</td>
<td className="text-end text-danger">{currency(sheet.cmv_total, sheet.currency)}</td>
<td className="text-end text-success">{currency(salePrice, sheet.currency)}</td>
<td className="text-end text-info">{currency(margin, sheet.currency)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</div>
)}
</>
);
};
export default PriceCalculatorTab;

View File

@ -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 (
<div className="modal show d-block" style={{ background: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div className="modal-content" style={{ background: '#1e293b', border: 'none' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className="bi bi-box-seam me-2"></i>
{isEditing ? t('business.products.edit') : t('business.products.add')}
</h5>
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div className="alert alert-danger">{typeof error === 'string' ? error : JSON.stringify(error)}</div>
)}
<div className="row g-3">
{/* Info básica */}
<div className="col-md-8">
<label className="form-label text-slate-400">{t('business.products.name')}</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t('business.products.namePlaceholder')}
required
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">SKU</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="sku"
value={formData.sku}
onChange={handleChange}
placeholder="ABC-001"
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('common.currency')}</label>
<select
className="form-select bg-dark text-white border-secondary"
name="currency"
value={formData.currency}
onChange={handleChange}
required
>
{currencies.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.products.category')}</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="category"
value={formData.category}
onChange={handleChange}
placeholder={t('business.products.categoryPlaceholder')}
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.products.businessSetting')}</label>
<select
className="form-select bg-dark text-white border-secondary"
name="business_setting_id"
value={formData.business_setting_id}
onChange={handleChange}
>
<option value="">{t('business.products.noSetting')}</option>
{settings.filter(s => s.is_active).map(s => (
<option key={s.id} value={s.id}>
{s.name} (Markup: {s.markup_factor?.toFixed(2)})
</option>
))}
</select>
</div>
<div className="col-12">
<label className="form-label text-slate-400">{t('common.description')}</label>
<textarea
className="form-control bg-dark text-white border-secondary"
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
/>
</div>
{/* Componentes de Custo (CMV) */}
<div className="col-12">
<hr className="border-secondary my-2" />
<div className="d-flex justify-content-between align-items-center mb-3">
<h6 className="text-slate-400 small mb-0">
<i className="bi bi-list-check me-2"></i>
{t('business.products.costComponents')}
</h6>
<button type="button" className="btn btn-sm btn-outline-primary" onClick={addItem}>
<i className="bi bi-plus-lg me-1"></i>
{t('business.products.addComponent')}
</button>
</div>
{items.length === 0 ? (
<div className="text-center py-4 text-slate-500">
<i className="bi bi-inbox fs-3 mb-2 d-block"></i>
{t('business.products.noComponents')}
</div>
) : (
<div className="table-responsive">
<table className="table table-dark table-sm mb-0">
<thead>
<tr>
<th className="text-slate-400" style={{ width: '30%' }}>{t('business.products.componentName')}</th>
<th className="text-slate-400" style={{ width: '20%' }}>{t('common.type')}</th>
<th className="text-slate-400 text-end" style={{ width: '20%' }}>{t('business.products.amount')}</th>
<th className="text-slate-400 text-end" style={{ width: '15%' }}>{t('business.products.quantity')}</th>
<th className="text-slate-400 text-end" style={{ width: '15%' }}>{t('business.products.unitCost')}</th>
<th style={{ width: '40px' }}></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const unitCost = (parseFloat(item.amount) || 0) / (parseFloat(item.quantity) || 1);
return (
<tr key={index}>
<td>
<input
type="text"
className="form-control form-control-sm bg-dark text-white border-secondary"
value={item.name}
onChange={(e) => handleItemChange(index, 'name', e.target.value)}
placeholder={t('business.products.componentNamePlaceholder')}
/>
</td>
<td>
<select
className="form-select form-select-sm bg-dark text-white border-secondary"
value={item.type}
onChange={(e) => handleItemChange(index, 'type', e.target.value)}
>
{itemTypes.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</td>
<td>
<input
type="number"
step="0.01"
className="form-control form-control-sm bg-dark text-white border-secondary text-end"
value={item.amount}
onChange={(e) => handleItemChange(index, 'amount', e.target.value)}
placeholder="0.00"
/>
</td>
<td>
<input
type="number"
step="0.01"
className="form-control form-control-sm bg-dark text-white border-secondary text-end"
value={item.quantity}
onChange={(e) => handleItemChange(index, 'quantity', e.target.value)}
placeholder="1"
/>
</td>
<td className="text-end text-white align-middle">
{currency(unitCost, formData.currency)}
</td>
<td>
<button
type="button"
className="btn btn-sm btn-outline-danger border-0"
onClick={() => removeItem(index)}
>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Preview CMV e Preço de Venda */}
<div className="col-12">
<div className="row g-3 mt-2">
<div className="col-md-6">
<div className="p-3 rounded text-center" style={{ background: 'rgba(239, 68, 68, 0.1)' }}>
<small className="text-slate-500 d-block mb-1">CMV Total</small>
<h4 className="text-danger mb-0">{currency(cmvTotal, formData.currency)}</h4>
</div>
</div>
<div className="col-md-6">
<div className="p-3 rounded text-center" style={{ background: salePrice ? 'rgba(16, 185, 129, 0.1)' : 'rgba(255,255,255,0.05)' }}>
<small className="text-slate-500 d-block mb-1">{t('business.products.salePrice')}</small>
<h4 className={salePrice ? 'text-success mb-0' : 'text-slate-500 mb-0'}>
{salePrice ? currency(salePrice, formData.currency) : '-'}
</h4>
{!formData.business_setting_id && (
<small className="text-slate-500">{t('business.products.selectSettingForPrice')}</small>
)}
</div>
</div>
</div>
</div>
{/* Status */}
{isEditing && (
<div className="col-12">
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
/>
<label className="form-check-label text-slate-400" htmlFor="is_active">
{t('business.products.isActive')}
</label>
</div>
</div>
)}
</div>
</div>
<div className="modal-footer border-0">
<button type="button" className="btn btn-secondary" onClick={onClose}>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
{t('common.save')}
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default ProductSheetModal;

View File

@ -0,0 +1,268 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { productSheetService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
import ProductSheetModal from './ProductSheetModal';
const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) => {
const { t } = useTranslation();
const { currency } = useFormatters();
const [showModal, setShowModal] = useState(false);
const [editingSheet, setEditingSheet] = useState(null);
const [deleting, setDeleting] = useState(null);
const [filter, setFilter] = useState({ category: '', active: 'all' });
const handleCreate = () => {
setEditingSheet(null);
setShowModal(true);
};
const handleEdit = (sheet) => {
setEditingSheet(sheet);
setShowModal(true);
};
const handleDuplicate = async (sheet) => {
try {
const duplicated = await productSheetService.duplicate(sheet.id);
onCreated(duplicated);
} catch (err) {
alert(err.response?.data?.message || t('common.error'));
}
};
const handleDelete = async (sheet) => {
if (!window.confirm(t('business.products.confirmDelete'))) return;
setDeleting(sheet.id);
try {
await productSheetService.delete(sheet.id);
onDeleted(sheet.id);
} catch (err) {
alert(err.response?.data?.message || t('common.error'));
} finally {
setDeleting(null);
}
};
const handleSave = (savedSheet) => {
if (editingSheet) {
onUpdated(savedSheet);
} else {
onCreated(savedSheet);
}
setShowModal(false);
};
// Filtrar fichas
const filteredSheets = sheets.filter(sheet => {
if (filter.category && sheet.category !== filter.category) return false;
if (filter.active === 'active' && !sheet.is_active) return false;
if (filter.active === 'inactive' && sheet.is_active) return false;
return true;
});
// Categorias únicas
const categories = [...new Set(sheets.map(s => s.category).filter(Boolean))];
return (
<>
{/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 className="text-white mb-1">{t('business.products.title')}</h5>
<p className="text-slate-400 small mb-0">{t('business.products.description')}</p>
</div>
<button className="btn btn-primary" onClick={handleCreate}>
<i className="bi bi-plus-lg me-2"></i>
{t('business.products.add')}
</button>
</div>
{/* Filtros */}
{sheets.length > 0 && (
<div className="row g-3 mb-4">
<div className="col-auto">
<select
className="form-select form-select-sm bg-dark text-white border-secondary"
value={filter.category}
onChange={(e) => setFilter(prev => ({ ...prev, category: e.target.value }))}
>
<option value="">{t('business.products.allCategories')}</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div className="col-auto">
<select
className="form-select form-select-sm bg-dark text-white border-secondary"
value={filter.active}
onChange={(e) => setFilter(prev => ({ ...prev, active: e.target.value }))}
>
<option value="all">{t('common.all')}</option>
<option value="active">{t('common.active')}</option>
<option value="inactive">{t('common.inactive')}</option>
</select>
</div>
</div>
)}
{/* Empty State */}
{sheets.length === 0 ? (
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-5">
<i className="bi bi-box-seam fs-1 text-slate-500 mb-3 d-block"></i>
<h5 className="text-white mb-2">{t('business.products.empty')}</h5>
<p className="text-slate-400 mb-4">{t('business.products.emptyDescription')}</p>
<button className="btn btn-primary" onClick={handleCreate}>
<i className="bi bi-plus-lg me-2"></i>
{t('business.products.createFirst')}
</button>
</div>
</div>
) : filteredSheets.length === 0 ? (
<div className="text-center py-5">
<i className="bi bi-funnel fs-1 text-slate-500 mb-3 d-block"></i>
<p className="text-slate-400">{t('business.products.noResults')}</p>
</div>
) : (
/* Products Grid */
<div className="row g-4">
{filteredSheets.map(sheet => (
<div key={sheet.id} className="col-12 col-md-6 col-xl-4">
<div
className="card border-0 h-100"
style={{
background: sheet.is_active ? '#1e293b' : '#0f172a',
opacity: sheet.is_active ? 1 : 0.7,
}}
>
<div className="card-header border-0 d-flex justify-content-between align-items-start py-3" style={{ background: 'transparent' }}>
<div>
<h6 className="text-white mb-1">
{sheet.name}
{!sheet.is_active && (
<span className="badge bg-secondary ms-2">{t('common.inactive')}</span>
)}
</h6>
{sheet.sku && <small className="text-slate-500">SKU: {sheet.sku}</small>}
{sheet.category && (
<span className="badge bg-secondary ms-2" style={{ fontSize: '10px' }}>{sheet.category}</span>
)}
</div>
<div className="dropdown">
<button className="btn btn-sm btn-outline-secondary border-0" data-bs-toggle="dropdown">
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end" style={{ background: '#1e293b' }}>
<li>
<button className="dropdown-item text-white" onClick={() => handleEdit(sheet)}>
<i className="bi bi-pencil me-2"></i>
{t('common.edit')}
</button>
</li>
<li>
<button className="dropdown-item text-white" onClick={() => handleDuplicate(sheet)}>
<i className="bi bi-copy me-2"></i>
{t('business.products.duplicate')}
</button>
</li>
<li><hr className="dropdown-divider" style={{ borderColor: 'rgba(255,255,255,0.1)' }} /></li>
<li>
<button
className="dropdown-item text-danger"
onClick={() => handleDelete(sheet)}
disabled={deleting === sheet.id}
>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</div>
<div className="card-body py-3">
{/* CMV e Preço de Venda */}
<div className="row g-2 mb-3">
<div className="col-6">
<div className="p-2 rounded text-center" style={{ background: 'rgba(239, 68, 68, 0.1)' }}>
<small className="text-slate-500 d-block">CMV</small>
<span className="text-danger fw-bold">
{currency(sheet.cmv_total, sheet.currency)}
</span>
</div>
</div>
<div className="col-6">
<div className="p-2 rounded text-center" style={{ background: 'rgba(16, 185, 129, 0.1)' }}>
<small className="text-slate-500 d-block">{t('business.products.salePrice')}</small>
<span className="text-success fw-bold">
{sheet.sale_price ? currency(sheet.sale_price, sheet.currency) : '-'}
</span>
</div>
</div>
</div>
{/* Margem de Contribuição */}
{sheet.contribution_margin !== null && (
<div className="d-flex justify-content-between align-items-center small mb-3 p-2 rounded" style={{ background: 'rgba(255,255,255,0.05)' }}>
<span className="text-slate-500">{t('business.products.contributionMargin')}</span>
<span className="text-info">
{currency(sheet.contribution_margin, sheet.currency)}
<span className="text-slate-500 ms-1">({sheet.contribution_margin_percent}%)</span>
</span>
</div>
)}
{/* Itens/Componentes */}
{sheet.items && sheet.items.length > 0 && (
<div className="small">
<small className="text-slate-500 d-block mb-2">{t('business.products.components')} ({sheet.items.length})</small>
<div style={{ maxHeight: '100px', overflowY: 'auto' }}>
{sheet.items.map(item => (
<div key={item.id} className="d-flex justify-content-between py-1" style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span className="text-slate-400 text-truncate" style={{ maxWidth: '60%' }}>{item.name}</span>
<span className="text-white">{currency(item.unit_cost, sheet.currency)}</span>
</div>
))}
</div>
</div>
)}
{/* Configuração usada */}
{sheet.business_setting && (
<div className="mt-3 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<small className="text-slate-500">
<i className="bi bi-gear me-1"></i>
{sheet.business_setting.name}
{sheet.markup_used && (
<span className="ms-2">
(Markup: {sheet.markup_used.toFixed(2)})
</span>
)}
</small>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{showModal && (
<ProductSheetModal
sheet={editingSheet}
settings={settings}
onSave={handleSave}
onClose={() => setShowModal(false)}
/>
)}
</>
);
};
export default ProductSheetsTab;

View File

@ -87,6 +87,7 @@
"costCenters": "Cost Centers",
"reports": "Reports",
"settings": "Settings",
"business": "Business",
"profile": "Profile",
"help": "Help"
},
@ -1074,5 +1075,109 @@
"skipWarning1": "The installment will be marked as skipped",
"skipWarning2": "No transaction will be created for it",
"skipWarning3": "Next installments will continue normally"
},
"business": {
"title": "Business",
"subtitle": "Manage pricing settings and product technical sheets",
"tabs": {
"settings": "Settings",
"products": "Products",
"calculator": "Calculator"
},
"settings": {
"title": "Markup Settings",
"description": "Define parameters to calculate the markup factor for each business unit",
"add": "New Setting",
"edit": "Edit Setting",
"name": "Name",
"namePlaceholder": "E.g.: Main Store",
"currency": "Currency",
"isActive": "Active",
"monthlyRevenue": "Monthly Revenue",
"fixedExpenses": "Monthly Fixed Expenses",
"fixedExpensesRate": "Fixed Expenses Rate",
"taxRate": "Taxes (%)",
"salesCommission": "Sales Commission (%)",
"cardFee": "Card Fee (%)",
"otherVariableCosts": "Other Variable Costs (%)",
"variableCosts": "Variable Costs",
"totalVariableCosts": "Total Variable Costs",
"investmentRate": "Investment Rate (%)",
"profitMargin": "Profit Margin (%)",
"markupFactor": "Markup Factor",
"totalDeductions": "Total Deductions",
"markupPreview": "Markup Preview",
"confirmDelete": "Are you sure you want to delete this setting?",
"deleteWarning": "This action cannot be undone. Associated products will be left without configuration.",
"noSettings": "No markup settings",
"createFirst": "Create your first setting to start calculating prices",
"errorTotalExceeds": "Total deductions cannot exceed 100%"
},
"products": {
"title": "Product Technical Sheets",
"description": "Manage the COGS (Cost of Goods Sold) for each product",
"add": "New Product",
"edit": "Edit Product",
"name": "Product Name",
"namePlaceholder": "E.g.: Basic T-Shirt",
"sku": "SKU/Code",
"category": "Category",
"categoryPlaceholder": "E.g.: Clothing",
"currency": "Currency",
"businessSetting": "Business Setting",
"selectSetting": "Select setting",
"isActive": "Active",
"cmvTotal": "Total COGS",
"salePrice": "Sale Price",
"contributionMargin": "Contribution Margin",
"noProducts": "No products registered",
"createFirst": "Create your first technical sheet to calculate prices",
"confirmDelete": "Are you sure you want to delete this product?",
"duplicate": "Duplicate",
"duplicateSuccess": "Product duplicated successfully",
"recalculate": "Recalculate",
"filterCategory": "Filter by category",
"filterStatus": "Filter by status",
"allCategories": "All categories",
"allStatus": "All statuses"
},
"items": {
"title": "Cost Components",
"add": "Add Component",
"name": "Name",
"namePlaceholder": "E.g.: Main fabric",
"type": "Type",
"amount": "Amount",
"quantity": "Quantity",
"unit": "Unit",
"unitCost": "Unit Cost",
"total": "Total",
"types": {
"product_cost": "Product Cost",
"packaging": "Packaging",
"label": "Label",
"shipping": "Shipping",
"handling": "Handling",
"other": "Other"
},
"noItems": "No cost components",
"addFirst": "Add the costs that make up the COGS"
},
"calculator": {
"title": "Price Calculator",
"description": "Simulate sale prices from COGS and markup settings",
"simulate": "Simulate Price",
"selectSetting": "Select Setting",
"enterCmv": "Enter COGS",
"cmvHelp": "Total cost of materials, packaging, shipping, etc.",
"salePrice": "Sale Price",
"contributionMargin": "Contribution Margin",
"marginPercent": "Margin %",
"formula": "Formula",
"breakdown": "Setting Breakdown",
"quickPrices": "Quick Product Prices",
"noSettings": "No Settings",
"createSettingFirst": "First create a markup setting in the Settings tab"
}
}
}

View File

@ -87,6 +87,7 @@
"costCenters": "Centros de Costo",
"reports": "Reportes",
"settings": "Configuración",
"business": "Negocio",
"profile": "Perfil",
"help": "Ayuda"
},
@ -1074,5 +1075,109 @@
"skipWarning1": "La cuota se marcará como omitida",
"skipWarning2": "No se creará ninguna transacción para ella",
"skipWarning3": "Las próximas cuotas continuarán normalmente"
},
"business": {
"title": "Negocio",
"subtitle": "Gestiona configuraciones de precios y fichas técnicas de productos",
"tabs": {
"settings": "Configuraciones",
"products": "Productos",
"calculator": "Calculadora"
},
"settings": {
"title": "Configuraciones de Markup",
"description": "Define los parámetros para calcular el factor de markup de cada unidad de negocio",
"add": "Nueva Configuración",
"edit": "Editar Configuración",
"name": "Nombre",
"namePlaceholder": "Ej: Tienda Principal",
"currency": "Moneda",
"isActive": "Activa",
"monthlyRevenue": "Facturación Mensual",
"fixedExpenses": "Gastos Fijos Mensuales",
"fixedExpensesRate": "Tasa de Gastos Fijos",
"taxRate": "Impuestos (%)",
"salesCommission": "Comisión de Venta (%)",
"cardFee": "Tasa de Tarjeta (%)",
"otherVariableCosts": "Otros Costos Variables (%)",
"variableCosts": "Costos Variables",
"totalVariableCosts": "Total Costos Variables",
"investmentRate": "Tasa de Inversión (%)",
"profitMargin": "Margen de Ganancia (%)",
"markupFactor": "Factor de Markup",
"totalDeductions": "Total de Deducciones",
"markupPreview": "Vista Previa del Markup",
"confirmDelete": "¿Seguro que deseas eliminar esta configuración?",
"deleteWarning": "Esta acción no se puede deshacer. Los productos asociados quedarán sin configuración.",
"noSettings": "No hay configuraciones de markup",
"createFirst": "Crea tu primera configuración para comenzar a calcular precios",
"errorTotalExceeds": "El total de deducciones no puede superar 100%"
},
"products": {
"title": "Fichas Técnicas de Productos",
"description": "Administra el CMV (Costo de Mercancía Vendida) de cada producto",
"add": "Nuevo Producto",
"edit": "Editar Producto",
"name": "Nombre del Producto",
"namePlaceholder": "Ej: Camiseta Básica",
"sku": "SKU/Código",
"category": "Categoría",
"categoryPlaceholder": "Ej: Ropa",
"currency": "Moneda",
"businessSetting": "Configuración de Negocio",
"selectSetting": "Seleccionar configuración",
"isActive": "Activo",
"cmvTotal": "CMV Total",
"salePrice": "Precio de Venta",
"contributionMargin": "Margen de Contribución",
"noProducts": "No hay productos registrados",
"createFirst": "Crea tu primera ficha técnica para calcular precios",
"confirmDelete": "¿Seguro que deseas eliminar este producto?",
"duplicate": "Duplicar",
"duplicateSuccess": "Producto duplicado correctamente",
"recalculate": "Recalcular",
"filterCategory": "Filtrar por categoría",
"filterStatus": "Filtrar por estado",
"allCategories": "Todas las categorías",
"allStatus": "Todos los estados"
},
"items": {
"title": "Componentes de Costo",
"add": "Agregar Componente",
"name": "Nombre",
"namePlaceholder": "Ej: Tela principal",
"type": "Tipo",
"amount": "Valor",
"quantity": "Cantidad",
"unit": "Unidad",
"unitCost": "Costo Unitario",
"total": "Total",
"types": {
"product_cost": "Costo del Producto",
"packaging": "Embalaje",
"label": "Etiqueta",
"shipping": "Envío",
"handling": "Manipulación",
"other": "Otros"
},
"noItems": "Sin componentes de costo",
"addFirst": "Agrega los costos que componen el CMV"
},
"calculator": {
"title": "Calculadora de Precios",
"description": "Simula precios de venta a partir del CMV y la configuración de markup",
"simulate": "Simular Precio",
"selectSetting": "Seleccionar Configuración",
"enterCmv": "Ingresa el CMV",
"cmvHelp": "Costo total de materiales, embalaje, envío, etc.",
"salePrice": "Precio de Venta",
"contributionMargin": "Margen de Contribución",
"marginPercent": "% de Margen",
"formula": "Fórmula",
"breakdown": "Desglose de la Configuración",
"quickPrices": "Precios Rápidos de Productos",
"noSettings": "Sin Configuraciones",
"createSettingFirst": "Primero crea una configuración de markup en la pestaña Configuraciones"
}
}
}

View File

@ -88,6 +88,7 @@
"costCenters": "Centros de Custo",
"reports": "Relatórios",
"settings": "Configurações",
"business": "Negócios",
"profile": "Perfil",
"help": "Ajuda"
},
@ -1076,5 +1077,109 @@
"skipWarning1": "A parcela será marcada como pulada",
"skipWarning2": "Nenhuma transação será criada para ela",
"skipWarning3": "As próximas parcelas continuarão normalmente"
},
"business": {
"title": "Negócios",
"subtitle": "Gerencie configurações de preços e fichas técnicas de produtos",
"tabs": {
"settings": "Configurações",
"products": "Produtos",
"calculator": "Calculadora"
},
"settings": {
"title": "Configurações de Markup",
"description": "Defina os parâmetros para calcular o fator de markup de cada unidade de negócio",
"add": "Nova Configuração",
"edit": "Editar Configuração",
"name": "Nome",
"namePlaceholder": "Ex: Loja Principal",
"currency": "Moeda",
"isActive": "Ativa",
"monthlyRevenue": "Faturamento Mensal",
"fixedExpenses": "Despesas Fixas Mensais",
"fixedExpensesRate": "Taxa de Despesas Fixas",
"taxRate": "Impostos (%)",
"salesCommission": "Comissão de Venda (%)",
"cardFee": "Taxa de Cartão (%)",
"otherVariableCosts": "Outros Custos Variáveis (%)",
"variableCosts": "Custos Variáveis",
"totalVariableCosts": "Total Custos Variáveis",
"investmentRate": "Taxa de Investimento (%)",
"profitMargin": "Margem de Lucro (%)",
"markupFactor": "Fator de Markup",
"totalDeductions": "Total de Deduções",
"markupPreview": "Pré-visualização do Markup",
"confirmDelete": "Tem certeza que deseja excluir esta configuração?",
"deleteWarning": "Esta ação não pode ser desfeita. Os produtos associados ficarão sem configuração.",
"noSettings": "Nenhuma configuração de markup",
"createFirst": "Crie sua primeira configuração para começar a calcular preços",
"errorTotalExceeds": "O total de deduções não pode exceder 100%"
},
"products": {
"title": "Fichas Técnicas de Produtos",
"description": "Gerencie o CMV (Custo da Mercadoria Vendida) de cada produto",
"add": "Novo Produto",
"edit": "Editar Produto",
"name": "Nome do Produto",
"namePlaceholder": "Ex: Camiseta Básica",
"sku": "SKU/Código",
"category": "Categoria",
"categoryPlaceholder": "Ex: Vestuário",
"currency": "Moeda",
"businessSetting": "Configuração de Negócio",
"selectSetting": "Selecionar configuração",
"isActive": "Ativo",
"cmvTotal": "CMV Total",
"salePrice": "Preço de Venda",
"contributionMargin": "Margem de Contribuição",
"noProducts": "Nenhum produto cadastrado",
"createFirst": "Crie sua primeira ficha técnica para calcular preços",
"confirmDelete": "Tem certeza que deseja excluir este produto?",
"duplicate": "Duplicar",
"duplicateSuccess": "Produto duplicado com sucesso",
"recalculate": "Recalcular",
"filterCategory": "Filtrar por categoria",
"filterStatus": "Filtrar por status",
"allCategories": "Todas as categorias",
"allStatus": "Todos os status"
},
"items": {
"title": "Componentes de Custo",
"add": "Adicionar Componente",
"name": "Nome",
"namePlaceholder": "Ex: Tecido principal",
"type": "Tipo",
"amount": "Valor",
"quantity": "Quantidade",
"unit": "Unidade",
"unitCost": "Custo Unitário",
"total": "Total",
"types": {
"product_cost": "Custo do Produto",
"packaging": "Embalagem",
"label": "Etiqueta",
"shipping": "Frete",
"handling": "Manuseio",
"other": "Outros"
},
"noItems": "Sem componentes de custo",
"addFirst": "Adicione os custos que compõem o CMV"
},
"calculator": {
"title": "Calculadora de Preços",
"description": "Simule preços de venda a partir do CMV e da configuração de markup",
"simulate": "Simular Preço",
"selectSetting": "Selecionar Configuração",
"enterCmv": "Informe o CMV",
"cmvHelp": "Custo total de materiais, embalagem, frete, etc.",
"salePrice": "Preço de Venda",
"contributionMargin": "Margem de Contribuição",
"marginPercent": "% de Margem",
"formula": "Fórmula",
"breakdown": "Detalhamento da Configuração",
"quickPrices": "Preços Rápidos de Produtos",
"noSettings": "Sem Configurações",
"createSettingFirst": "Primeiro crie uma configuração de markup na aba Configurações"
}
}
}

View File

@ -0,0 +1,166 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { businessSettingService, productSheetService } from '../services/api';
import useFormatters from '../hooks/useFormatters';
import BusinessSettingsTab from '../components/business/BusinessSettingsTab';
import ProductSheetsTab from '../components/business/ProductSheetsTab';
import PriceCalculatorTab from '../components/business/PriceCalculatorTab';
const Business = () => {
const { t } = useTranslation();
const { currency } = useFormatters();
const [activeTab, setActiveTab] = useState('settings');
const [settings, setSettings] = useState([]);
const [sheets, setSheets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Carregar dados
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [settingsData, sheetsData] = await Promise.all([
businessSettingService.getAll(),
productSheetService.getAll(),
]);
setSettings(settingsData);
setSheets(sheetsData);
} catch (err) {
console.error('Error loading business data:', err);
setError(err.response?.data?.message || t('common.error'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
loadData();
}, [loadData]);
// Handlers para Settings
const handleSettingCreated = (newSetting) => {
setSettings(prev => [...prev, newSetting]);
};
const handleSettingUpdated = (updatedSetting) => {
setSettings(prev => prev.map(s => s.id === updatedSetting.id ? updatedSetting : s));
};
const handleSettingDeleted = (id) => {
setSettings(prev => prev.filter(s => s.id !== id));
};
// Handlers para Sheets
const handleSheetCreated = (newSheet) => {
setSheets(prev => [...prev, newSheet]);
};
const handleSheetUpdated = (updatedSheet) => {
setSheets(prev => prev.map(s => s.id === updatedSheet.id ? updatedSheet : s));
};
const handleSheetDeleted = (id) => {
setSheets(prev => prev.filter(s => s.id !== id));
};
const tabs = [
{ id: 'settings', label: t('business.tabs.settings'), icon: 'bi-gear' },
{ id: 'products', label: t('business.tabs.products'), icon: 'bi-box-seam' },
{ id: 'calculator', label: t('business.tabs.calculator'), icon: 'bi-calculator' },
];
return (
<div className="container-fluid py-4">
{/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 className="h3 mb-1 text-white">
<i className="bi bi-briefcase me-2"></i>
{t('business.title')}
</h1>
<p className="text-slate-400 mb-0 small">
{t('business.subtitle')}
</p>
</div>
<button
className="btn btn-sm btn-outline-secondary"
onClick={loadData}
disabled={loading}
>
<i className={`bi bi-arrow-clockwise ${loading ? 'spin' : ''}`}></i>
</button>
</div>
{/* Error Alert */}
{error && (
<div className="alert alert-danger d-flex align-items-center mb-4" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i>
{error}
<button type="button" className="btn-close ms-auto" onClick={() => setError(null)}></button>
</div>
)}
{/* Tabs */}
<ul className="nav nav-tabs mb-4" style={{ borderColor: 'rgba(255,255,255,0.1)' }}>
{tabs.map(tab => (
<li key={tab.id} className="nav-item">
<button
className={`nav-link ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
style={{
background: activeTab === tab.id ? 'rgba(59, 130, 246, 0.2)' : 'transparent',
color: activeTab === tab.id ? '#3b82f6' : '#94a3b8',
border: 'none',
borderBottom: activeTab === tab.id ? '2px solid #3b82f6' : '2px solid transparent',
}}
>
<i className={`bi ${tab.icon} me-2`}></i>
{tab.label}
</button>
</li>
))}
</ul>
{/* Tab Content */}
{loading ? (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
) : (
<>
{activeTab === 'settings' && (
<BusinessSettingsTab
settings={settings}
onCreated={handleSettingCreated}
onUpdated={handleSettingUpdated}
onDeleted={handleSettingDeleted}
/>
)}
{activeTab === 'products' && (
<ProductSheetsTab
sheets={sheets}
settings={settings}
onCreated={handleSheetCreated}
onUpdated={handleSheetUpdated}
onDeleted={handleSheetDeleted}
/>
)}
{activeTab === 'calculator' && (
<PriceCalculatorTab
settings={settings}
sheets={sheets}
/>
)}
</>
)}
</div>
);
};
export default Business;

View File

@ -995,4 +995,136 @@ export const recurringService = {
},
};
// ============================================
// Business Settings Services (Configurações de Negócio - Markup)
// ============================================
export const businessSettingService = {
// Listar todas as configurações
getAll: async () => {
const response = await api.get('/business-settings');
return response.data;
},
// Obter configuração padrão ativa
getDefault: async () => {
const response = await api.get('/business-settings/default');
return response.data;
},
// Obter uma configuração específica
getById: async (id) => {
const response = await api.get(`/business-settings/${id}`);
return response.data;
},
// Criar nova configuração
create: async (data) => {
const response = await api.post('/business-settings', data);
return response.data;
},
// Atualizar configuração
update: async (id, data) => {
const response = await api.put(`/business-settings/${id}`, data);
return response.data;
},
// Excluir configuração
delete: async (id) => {
const response = await api.delete(`/business-settings/${id}`);
return response.data;
},
// Recalcular markup
recalculateMarkup: async (id) => {
const response = await api.post(`/business-settings/${id}/recalculate-markup`);
return response.data;
},
// Simular preço de venda
simulatePrice: async (id, cmv) => {
const response = await api.post(`/business-settings/${id}/simulate-price`, { cmv });
return response.data;
},
};
// ============================================
// Product Sheet Services (Fichas Técnicas - CMV)
// ============================================
export const productSheetService = {
// Listar todas as fichas
getAll: async (params = {}) => {
const response = await api.get('/product-sheets', { params });
return response.data;
},
// Obter uma ficha específica
getById: async (id) => {
const response = await api.get(`/product-sheets/${id}`);
return response.data;
},
// Criar nova ficha
create: async (data) => {
const response = await api.post('/product-sheets', data);
return response.data;
},
// Atualizar ficha
update: async (id, data) => {
const response = await api.put(`/product-sheets/${id}`, data);
return response.data;
},
// Excluir ficha
delete: async (id) => {
const response = await api.delete(`/product-sheets/${id}`);
return response.data;
},
// Adicionar item à ficha
addItem: async (sheetId, itemData) => {
const response = await api.post(`/product-sheets/${sheetId}/items`, itemData);
return response.data;
},
// Atualizar item da ficha
updateItem: async (sheetId, itemId, itemData) => {
const response = await api.put(`/product-sheets/${sheetId}/items/${itemId}`, itemData);
return response.data;
},
// Remover item da ficha
removeItem: async (sheetId, itemId) => {
const response = await api.delete(`/product-sheets/${sheetId}/items/${itemId}`);
return response.data;
},
// Recalcular preço de venda
recalculatePrice: async (id, businessSettingId = null) => {
const response = await api.post(`/product-sheets/${id}/recalculate-price`, {
business_setting_id: businessSettingId,
});
return response.data;
},
// Duplicar ficha
duplicate: async (id) => {
const response = await api.post(`/product-sheets/${id}/duplicate`);
return response.data;
},
// Listar categorias
getCategories: async () => {
const response = await api.get('/product-sheets/categories');
return response.data;
},
// Listar tipos de componentes
getItemTypes: async () => {
const response = await api.get('/product-sheets/item-types');
return response.data;
},
};
export default api;