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:
parent
9dc9f1a0a1
commit
84d9d7d187
31
CHANGELOG.md
31
CHANGELOG.md
@ -5,6 +5,37 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
|
|||||||
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
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
|
## [1.27.6] - 2025-12-13
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
215
backend/app/Http/Controllers/Api/BusinessSettingController.php
Normal file
215
backend/app/Http/Controllers/Api/BusinessSettingController.php
Normal 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,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
398
backend/app/Http/Controllers/Api/ProductSheetController.php
Normal file
398
backend/app/Http/Controllers/Api/ProductSheetController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
backend/app/Models/BusinessSetting.php
Normal file
180
backend/app/Models/BusinessSetting.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
backend/app/Models/ProductSheet.php
Normal file
139
backend/app/Models/ProductSheet.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
backend/app/Models/ProductSheetItem.php
Normal file
104
backend/app/Models/ProductSheetItem.php
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -13,6 +13,8 @@
|
|||||||
use App\Http\Controllers\Api\TransferDetectionController;
|
use App\Http\Controllers\Api\TransferDetectionController;
|
||||||
use App\Http\Controllers\Api\DashboardController;
|
use App\Http\Controllers\Api\DashboardController;
|
||||||
use App\Http\Controllers\Api\RecurringTemplateController;
|
use App\Http\Controllers\Api\RecurringTemplateController;
|
||||||
|
use App\Http\Controllers\Api\BusinessSettingController;
|
||||||
|
use App\Http\Controllers\Api\ProductSheetController;
|
||||||
|
|
||||||
// Public routes with rate limiting
|
// Public routes with rate limiting
|
||||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
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}/skip', [RecurringTemplateController::class, 'skip']);
|
||||||
Route::post('recurring-instances/{recurringInstance}/cancel', [RecurringTemplateController::class, 'cancel']);
|
Route::post('recurring-instances/{recurringInstance}/cancel', [RecurringTemplateController::class, 'cancel']);
|
||||||
Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']);
|
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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import ImportTransactions from './pages/ImportTransactions';
|
|||||||
import TransferDetection from './pages/TransferDetection';
|
import TransferDetection from './pages/TransferDetection';
|
||||||
import RefundDetection from './pages/RefundDetection';
|
import RefundDetection from './pages/RefundDetection';
|
||||||
import RecurringTransactions from './pages/RecurringTransactions';
|
import RecurringTransactions from './pages/RecurringTransactions';
|
||||||
|
import Business from './pages/Business';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -124,6 +125,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/business"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Business />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
|||||||
@ -63,6 +63,7 @@ const Layout = ({ children }) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
|
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
|
||||||
|
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') },
|
||||||
{
|
{
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
|
|||||||
412
frontend/src/components/business/BusinessSettingModal.jsx
Normal file
412
frontend/src/components/business/BusinessSettingModal.jsx
Normal 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;
|
||||||
211
frontend/src/components/business/BusinessSettingsTab.jsx
Normal file
211
frontend/src/components/business/BusinessSettingsTab.jsx
Normal 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;
|
||||||
315
frontend/src/components/business/PriceCalculatorTab.jsx
Normal file
315
frontend/src/components/business/PriceCalculatorTab.jsx
Normal 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;
|
||||||
450
frontend/src/components/business/ProductSheetModal.jsx
Normal file
450
frontend/src/components/business/ProductSheetModal.jsx
Normal 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;
|
||||||
268
frontend/src/components/business/ProductSheetsTab.jsx
Normal file
268
frontend/src/components/business/ProductSheetsTab.jsx
Normal 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;
|
||||||
@ -87,6 +87,7 @@
|
|||||||
"costCenters": "Cost Centers",
|
"costCenters": "Cost Centers",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"business": "Business",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"help": "Help"
|
"help": "Help"
|
||||||
},
|
},
|
||||||
@ -1074,5 +1075,109 @@
|
|||||||
"skipWarning1": "The installment will be marked as skipped",
|
"skipWarning1": "The installment will be marked as skipped",
|
||||||
"skipWarning2": "No transaction will be created for it",
|
"skipWarning2": "No transaction will be created for it",
|
||||||
"skipWarning3": "Next installments will continue normally"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,7 @@
|
|||||||
"costCenters": "Centros de Costo",
|
"costCenters": "Centros de Costo",
|
||||||
"reports": "Reportes",
|
"reports": "Reportes",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
|
"business": "Negocio",
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"help": "Ayuda"
|
"help": "Ayuda"
|
||||||
},
|
},
|
||||||
@ -1074,5 +1075,109 @@
|
|||||||
"skipWarning1": "La cuota se marcará como omitida",
|
"skipWarning1": "La cuota se marcará como omitida",
|
||||||
"skipWarning2": "No se creará ninguna transacción para ella",
|
"skipWarning2": "No se creará ninguna transacción para ella",
|
||||||
"skipWarning3": "Las próximas cuotas continuarán normalmente"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,7 @@
|
|||||||
"costCenters": "Centros de Custo",
|
"costCenters": "Centros de Custo",
|
||||||
"reports": "Relatórios",
|
"reports": "Relatórios",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
|
"business": "Negócios",
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"help": "Ajuda"
|
"help": "Ajuda"
|
||||||
},
|
},
|
||||||
@ -1076,5 +1077,109 @@
|
|||||||
"skipWarning1": "A parcela será marcada como pulada",
|
"skipWarning1": "A parcela será marcada como pulada",
|
||||||
"skipWarning2": "Nenhuma transação será criada para ela",
|
"skipWarning2": "Nenhuma transação será criada para ela",
|
||||||
"skipWarning3": "As próximas parcelas continuarão normalmente"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
166
frontend/src/pages/Business.jsx
Normal file
166
frontend/src/pages/Business.jsx
Normal 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;
|
||||||
@ -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;
|
export default api;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user