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/).
|
||||
|
||||
|
||||
## [1.28.0] - 2025-12-14
|
||||
|
||||
### Added
|
||||
- **Nueva Sección Business** - Módulo completo para cálculo de precios con Markup
|
||||
- **Configuraciones de Markup**: Define parámetros de cada unidad de negocio
|
||||
- Facturación mensual y gastos fijos
|
||||
- Costos variables: impuestos, comisiones, tasas de tarjeta
|
||||
- Tasa de inversión y margen de ganancia
|
||||
- Cálculo automático del factor de markup: `Markup = 1 / (1 - deductions)`
|
||||
|
||||
- **Fichas Técnicas de Productos**: Gestión del CMV (Costo de Mercancía Vendida)
|
||||
- Componentes de costo: producto, embalaje, etiqueta, envío, manipulación, otros
|
||||
- Cálculo automático del CMV total y precio de venta sugerido
|
||||
- Margen de contribución calculado en tiempo real
|
||||
- Función duplicar para crear variantes rápidamente
|
||||
|
||||
- **Calculadora de Precios**: Simulador interactivo
|
||||
- Ingresa un CMV y visualiza precio de venta instantáneamente
|
||||
- Desglose completo de la configuración seleccionada
|
||||
- Tabla de precios rápidos para productos existentes
|
||||
- Fórmula visible: `Precio de Venta = CMV × Markup`
|
||||
|
||||
- **Multi-divisa**: Soporte completo para EUR, BRL, USD
|
||||
|
||||
### Technical
|
||||
- Backend: 3 migraciones, 3 modelos, 2 controladores con endpoints especializados
|
||||
- Frontend: Página Business.jsx con 3 tabs y 5 componentes React
|
||||
- API: Routes para business-settings y product-sheets con CRUD completo
|
||||
- i18n: Traducciones completas en ES, PT-BR, EN
|
||||
|
||||
|
||||
## [1.27.6] - 2025-12-13
|
||||
|
||||
### Fixed
|
||||
|
||||
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\DashboardController;
|
||||
use App\Http\Controllers\Api\RecurringTemplateController;
|
||||
use App\Http\Controllers\Api\BusinessSettingController;
|
||||
use App\Http\Controllers\Api\ProductSheetController;
|
||||
|
||||
// Public routes with rate limiting
|
||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||
@ -189,5 +191,25 @@
|
||||
Route::post('recurring-instances/{recurringInstance}/skip', [RecurringTemplateController::class, 'skip']);
|
||||
Route::post('recurring-instances/{recurringInstance}/cancel', [RecurringTemplateController::class, 'cancel']);
|
||||
Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']);
|
||||
|
||||
// ============================================
|
||||
// Business - Precificação de Produtos
|
||||
// ============================================
|
||||
|
||||
// Configurações de Negócio (Markup)
|
||||
Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']);
|
||||
Route::apiResource('business-settings', BusinessSettingController::class);
|
||||
Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']);
|
||||
Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']);
|
||||
|
||||
// Fichas Técnicas de Produtos (CMV)
|
||||
Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']);
|
||||
Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']);
|
||||
Route::apiResource('product-sheets', ProductSheetController::class);
|
||||
Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']);
|
||||
Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']);
|
||||
Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']);
|
||||
Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']);
|
||||
Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']);
|
||||
});
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ import ImportTransactions from './pages/ImportTransactions';
|
||||
import TransferDetection from './pages/TransferDetection';
|
||||
import RefundDetection from './pages/RefundDetection';
|
||||
import RecurringTransactions from './pages/RecurringTransactions';
|
||||
import Business from './pages/Business';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -124,6 +125,16 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/business"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Business />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
<CookieConsent />
|
||||
|
||||
@ -63,6 +63,7 @@ const Layout = ({ children }) => {
|
||||
]
|
||||
},
|
||||
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
|
||||
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') },
|
||||
{
|
||||
type: 'group',
|
||||
id: 'settings',
|
||||
|
||||
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",
|
||||
"reports": "Reports",
|
||||
"settings": "Settings",
|
||||
"business": "Business",
|
||||
"profile": "Profile",
|
||||
"help": "Help"
|
||||
},
|
||||
@ -1074,5 +1075,109 @@
|
||||
"skipWarning1": "The installment will be marked as skipped",
|
||||
"skipWarning2": "No transaction will be created for it",
|
||||
"skipWarning3": "Next installments will continue normally"
|
||||
},
|
||||
"business": {
|
||||
"title": "Business",
|
||||
"subtitle": "Manage pricing settings and product technical sheets",
|
||||
"tabs": {
|
||||
"settings": "Settings",
|
||||
"products": "Products",
|
||||
"calculator": "Calculator"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Markup Settings",
|
||||
"description": "Define parameters to calculate the markup factor for each business unit",
|
||||
"add": "New Setting",
|
||||
"edit": "Edit Setting",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "E.g.: Main Store",
|
||||
"currency": "Currency",
|
||||
"isActive": "Active",
|
||||
"monthlyRevenue": "Monthly Revenue",
|
||||
"fixedExpenses": "Monthly Fixed Expenses",
|
||||
"fixedExpensesRate": "Fixed Expenses Rate",
|
||||
"taxRate": "Taxes (%)",
|
||||
"salesCommission": "Sales Commission (%)",
|
||||
"cardFee": "Card Fee (%)",
|
||||
"otherVariableCosts": "Other Variable Costs (%)",
|
||||
"variableCosts": "Variable Costs",
|
||||
"totalVariableCosts": "Total Variable Costs",
|
||||
"investmentRate": "Investment Rate (%)",
|
||||
"profitMargin": "Profit Margin (%)",
|
||||
"markupFactor": "Markup Factor",
|
||||
"totalDeductions": "Total Deductions",
|
||||
"markupPreview": "Markup Preview",
|
||||
"confirmDelete": "Are you sure you want to delete this setting?",
|
||||
"deleteWarning": "This action cannot be undone. Associated products will be left without configuration.",
|
||||
"noSettings": "No markup settings",
|
||||
"createFirst": "Create your first setting to start calculating prices",
|
||||
"errorTotalExceeds": "Total deductions cannot exceed 100%"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product Technical Sheets",
|
||||
"description": "Manage the COGS (Cost of Goods Sold) for each product",
|
||||
"add": "New Product",
|
||||
"edit": "Edit Product",
|
||||
"name": "Product Name",
|
||||
"namePlaceholder": "E.g.: Basic T-Shirt",
|
||||
"sku": "SKU/Code",
|
||||
"category": "Category",
|
||||
"categoryPlaceholder": "E.g.: Clothing",
|
||||
"currency": "Currency",
|
||||
"businessSetting": "Business Setting",
|
||||
"selectSetting": "Select setting",
|
||||
"isActive": "Active",
|
||||
"cmvTotal": "Total COGS",
|
||||
"salePrice": "Sale Price",
|
||||
"contributionMargin": "Contribution Margin",
|
||||
"noProducts": "No products registered",
|
||||
"createFirst": "Create your first technical sheet to calculate prices",
|
||||
"confirmDelete": "Are you sure you want to delete this product?",
|
||||
"duplicate": "Duplicate",
|
||||
"duplicateSuccess": "Product duplicated successfully",
|
||||
"recalculate": "Recalculate",
|
||||
"filterCategory": "Filter by category",
|
||||
"filterStatus": "Filter by status",
|
||||
"allCategories": "All categories",
|
||||
"allStatus": "All statuses"
|
||||
},
|
||||
"items": {
|
||||
"title": "Cost Components",
|
||||
"add": "Add Component",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "E.g.: Main fabric",
|
||||
"type": "Type",
|
||||
"amount": "Amount",
|
||||
"quantity": "Quantity",
|
||||
"unit": "Unit",
|
||||
"unitCost": "Unit Cost",
|
||||
"total": "Total",
|
||||
"types": {
|
||||
"product_cost": "Product Cost",
|
||||
"packaging": "Packaging",
|
||||
"label": "Label",
|
||||
"shipping": "Shipping",
|
||||
"handling": "Handling",
|
||||
"other": "Other"
|
||||
},
|
||||
"noItems": "No cost components",
|
||||
"addFirst": "Add the costs that make up the COGS"
|
||||
},
|
||||
"calculator": {
|
||||
"title": "Price Calculator",
|
||||
"description": "Simulate sale prices from COGS and markup settings",
|
||||
"simulate": "Simulate Price",
|
||||
"selectSetting": "Select Setting",
|
||||
"enterCmv": "Enter COGS",
|
||||
"cmvHelp": "Total cost of materials, packaging, shipping, etc.",
|
||||
"salePrice": "Sale Price",
|
||||
"contributionMargin": "Contribution Margin",
|
||||
"marginPercent": "Margin %",
|
||||
"formula": "Formula",
|
||||
"breakdown": "Setting Breakdown",
|
||||
"quickPrices": "Quick Product Prices",
|
||||
"noSettings": "No Settings",
|
||||
"createSettingFirst": "First create a markup setting in the Settings tab"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,7 @@
|
||||
"costCenters": "Centros de Costo",
|
||||
"reports": "Reportes",
|
||||
"settings": "Configuración",
|
||||
"business": "Negocio",
|
||||
"profile": "Perfil",
|
||||
"help": "Ayuda"
|
||||
},
|
||||
@ -1074,5 +1075,109 @@
|
||||
"skipWarning1": "La cuota se marcará como omitida",
|
||||
"skipWarning2": "No se creará ninguna transacción para ella",
|
||||
"skipWarning3": "Las próximas cuotas continuarán normalmente"
|
||||
},
|
||||
"business": {
|
||||
"title": "Negocio",
|
||||
"subtitle": "Gestiona configuraciones de precios y fichas técnicas de productos",
|
||||
"tabs": {
|
||||
"settings": "Configuraciones",
|
||||
"products": "Productos",
|
||||
"calculator": "Calculadora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuraciones de Markup",
|
||||
"description": "Define los parámetros para calcular el factor de markup de cada unidad de negocio",
|
||||
"add": "Nueva Configuración",
|
||||
"edit": "Editar Configuración",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "Ej: Tienda Principal",
|
||||
"currency": "Moneda",
|
||||
"isActive": "Activa",
|
||||
"monthlyRevenue": "Facturación Mensual",
|
||||
"fixedExpenses": "Gastos Fijos Mensuales",
|
||||
"fixedExpensesRate": "Tasa de Gastos Fijos",
|
||||
"taxRate": "Impuestos (%)",
|
||||
"salesCommission": "Comisión de Venta (%)",
|
||||
"cardFee": "Tasa de Tarjeta (%)",
|
||||
"otherVariableCosts": "Otros Costos Variables (%)",
|
||||
"variableCosts": "Costos Variables",
|
||||
"totalVariableCosts": "Total Costos Variables",
|
||||
"investmentRate": "Tasa de Inversión (%)",
|
||||
"profitMargin": "Margen de Ganancia (%)",
|
||||
"markupFactor": "Factor de Markup",
|
||||
"totalDeductions": "Total de Deducciones",
|
||||
"markupPreview": "Vista Previa del Markup",
|
||||
"confirmDelete": "¿Seguro que deseas eliminar esta configuración?",
|
||||
"deleteWarning": "Esta acción no se puede deshacer. Los productos asociados quedarán sin configuración.",
|
||||
"noSettings": "No hay configuraciones de markup",
|
||||
"createFirst": "Crea tu primera configuración para comenzar a calcular precios",
|
||||
"errorTotalExceeds": "El total de deducciones no puede superar 100%"
|
||||
},
|
||||
"products": {
|
||||
"title": "Fichas Técnicas de Productos",
|
||||
"description": "Administra el CMV (Costo de Mercancía Vendida) de cada producto",
|
||||
"add": "Nuevo Producto",
|
||||
"edit": "Editar Producto",
|
||||
"name": "Nombre del Producto",
|
||||
"namePlaceholder": "Ej: Camiseta Básica",
|
||||
"sku": "SKU/Código",
|
||||
"category": "Categoría",
|
||||
"categoryPlaceholder": "Ej: Ropa",
|
||||
"currency": "Moneda",
|
||||
"businessSetting": "Configuración de Negocio",
|
||||
"selectSetting": "Seleccionar configuración",
|
||||
"isActive": "Activo",
|
||||
"cmvTotal": "CMV Total",
|
||||
"salePrice": "Precio de Venta",
|
||||
"contributionMargin": "Margen de Contribución",
|
||||
"noProducts": "No hay productos registrados",
|
||||
"createFirst": "Crea tu primera ficha técnica para calcular precios",
|
||||
"confirmDelete": "¿Seguro que deseas eliminar este producto?",
|
||||
"duplicate": "Duplicar",
|
||||
"duplicateSuccess": "Producto duplicado correctamente",
|
||||
"recalculate": "Recalcular",
|
||||
"filterCategory": "Filtrar por categoría",
|
||||
"filterStatus": "Filtrar por estado",
|
||||
"allCategories": "Todas las categorías",
|
||||
"allStatus": "Todos los estados"
|
||||
},
|
||||
"items": {
|
||||
"title": "Componentes de Costo",
|
||||
"add": "Agregar Componente",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "Ej: Tela principal",
|
||||
"type": "Tipo",
|
||||
"amount": "Valor",
|
||||
"quantity": "Cantidad",
|
||||
"unit": "Unidad",
|
||||
"unitCost": "Costo Unitario",
|
||||
"total": "Total",
|
||||
"types": {
|
||||
"product_cost": "Costo del Producto",
|
||||
"packaging": "Embalaje",
|
||||
"label": "Etiqueta",
|
||||
"shipping": "Envío",
|
||||
"handling": "Manipulación",
|
||||
"other": "Otros"
|
||||
},
|
||||
"noItems": "Sin componentes de costo",
|
||||
"addFirst": "Agrega los costos que componen el CMV"
|
||||
},
|
||||
"calculator": {
|
||||
"title": "Calculadora de Precios",
|
||||
"description": "Simula precios de venta a partir del CMV y la configuración de markup",
|
||||
"simulate": "Simular Precio",
|
||||
"selectSetting": "Seleccionar Configuración",
|
||||
"enterCmv": "Ingresa el CMV",
|
||||
"cmvHelp": "Costo total de materiales, embalaje, envío, etc.",
|
||||
"salePrice": "Precio de Venta",
|
||||
"contributionMargin": "Margen de Contribución",
|
||||
"marginPercent": "% de Margen",
|
||||
"formula": "Fórmula",
|
||||
"breakdown": "Desglose de la Configuración",
|
||||
"quickPrices": "Precios Rápidos de Productos",
|
||||
"noSettings": "Sin Configuraciones",
|
||||
"createSettingFirst": "Primero crea una configuración de markup en la pestaña Configuraciones"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"costCenters": "Centros de Custo",
|
||||
"reports": "Relatórios",
|
||||
"settings": "Configurações",
|
||||
"business": "Negócios",
|
||||
"profile": "Perfil",
|
||||
"help": "Ajuda"
|
||||
},
|
||||
@ -1076,5 +1077,109 @@
|
||||
"skipWarning1": "A parcela será marcada como pulada",
|
||||
"skipWarning2": "Nenhuma transação será criada para ela",
|
||||
"skipWarning3": "As próximas parcelas continuarão normalmente"
|
||||
},
|
||||
"business": {
|
||||
"title": "Negócios",
|
||||
"subtitle": "Gerencie configurações de preços e fichas técnicas de produtos",
|
||||
"tabs": {
|
||||
"settings": "Configurações",
|
||||
"products": "Produtos",
|
||||
"calculator": "Calculadora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações de Markup",
|
||||
"description": "Defina os parâmetros para calcular o fator de markup de cada unidade de negócio",
|
||||
"add": "Nova Configuração",
|
||||
"edit": "Editar Configuração",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "Ex: Loja Principal",
|
||||
"currency": "Moeda",
|
||||
"isActive": "Ativa",
|
||||
"monthlyRevenue": "Faturamento Mensal",
|
||||
"fixedExpenses": "Despesas Fixas Mensais",
|
||||
"fixedExpensesRate": "Taxa de Despesas Fixas",
|
||||
"taxRate": "Impostos (%)",
|
||||
"salesCommission": "Comissão de Venda (%)",
|
||||
"cardFee": "Taxa de Cartão (%)",
|
||||
"otherVariableCosts": "Outros Custos Variáveis (%)",
|
||||
"variableCosts": "Custos Variáveis",
|
||||
"totalVariableCosts": "Total Custos Variáveis",
|
||||
"investmentRate": "Taxa de Investimento (%)",
|
||||
"profitMargin": "Margem de Lucro (%)",
|
||||
"markupFactor": "Fator de Markup",
|
||||
"totalDeductions": "Total de Deduções",
|
||||
"markupPreview": "Pré-visualização do Markup",
|
||||
"confirmDelete": "Tem certeza que deseja excluir esta configuração?",
|
||||
"deleteWarning": "Esta ação não pode ser desfeita. Os produtos associados ficarão sem configuração.",
|
||||
"noSettings": "Nenhuma configuração de markup",
|
||||
"createFirst": "Crie sua primeira configuração para começar a calcular preços",
|
||||
"errorTotalExceeds": "O total de deduções não pode exceder 100%"
|
||||
},
|
||||
"products": {
|
||||
"title": "Fichas Técnicas de Produtos",
|
||||
"description": "Gerencie o CMV (Custo da Mercadoria Vendida) de cada produto",
|
||||
"add": "Novo Produto",
|
||||
"edit": "Editar Produto",
|
||||
"name": "Nome do Produto",
|
||||
"namePlaceholder": "Ex: Camiseta Básica",
|
||||
"sku": "SKU/Código",
|
||||
"category": "Categoria",
|
||||
"categoryPlaceholder": "Ex: Vestuário",
|
||||
"currency": "Moeda",
|
||||
"businessSetting": "Configuração de Negócio",
|
||||
"selectSetting": "Selecionar configuração",
|
||||
"isActive": "Ativo",
|
||||
"cmvTotal": "CMV Total",
|
||||
"salePrice": "Preço de Venda",
|
||||
"contributionMargin": "Margem de Contribuição",
|
||||
"noProducts": "Nenhum produto cadastrado",
|
||||
"createFirst": "Crie sua primeira ficha técnica para calcular preços",
|
||||
"confirmDelete": "Tem certeza que deseja excluir este produto?",
|
||||
"duplicate": "Duplicar",
|
||||
"duplicateSuccess": "Produto duplicado com sucesso",
|
||||
"recalculate": "Recalcular",
|
||||
"filterCategory": "Filtrar por categoria",
|
||||
"filterStatus": "Filtrar por status",
|
||||
"allCategories": "Todas as categorias",
|
||||
"allStatus": "Todos os status"
|
||||
},
|
||||
"items": {
|
||||
"title": "Componentes de Custo",
|
||||
"add": "Adicionar Componente",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "Ex: Tecido principal",
|
||||
"type": "Tipo",
|
||||
"amount": "Valor",
|
||||
"quantity": "Quantidade",
|
||||
"unit": "Unidade",
|
||||
"unitCost": "Custo Unitário",
|
||||
"total": "Total",
|
||||
"types": {
|
||||
"product_cost": "Custo do Produto",
|
||||
"packaging": "Embalagem",
|
||||
"label": "Etiqueta",
|
||||
"shipping": "Frete",
|
||||
"handling": "Manuseio",
|
||||
"other": "Outros"
|
||||
},
|
||||
"noItems": "Sem componentes de custo",
|
||||
"addFirst": "Adicione os custos que compõem o CMV"
|
||||
},
|
||||
"calculator": {
|
||||
"title": "Calculadora de Preços",
|
||||
"description": "Simule preços de venda a partir do CMV e da configuração de markup",
|
||||
"simulate": "Simular Preço",
|
||||
"selectSetting": "Selecionar Configuração",
|
||||
"enterCmv": "Informe o CMV",
|
||||
"cmvHelp": "Custo total de materiais, embalagem, frete, etc.",
|
||||
"salePrice": "Preço de Venda",
|
||||
"contributionMargin": "Margem de Contribuição",
|
||||
"marginPercent": "% de Margem",
|
||||
"formula": "Fórmula",
|
||||
"breakdown": "Detalhamento da Configuração",
|
||||
"quickPrices": "Preços Rápidos de Produtos",
|
||||
"noSettings": "Sem Configurações",
|
||||
"createSettingFirst": "Primeiro crie uma configuração de markup na aba Configurações"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user