v1.30.0: Fichas de Servicios, Horas Productivas, i18n completo, fix modal responsivo

This commit is contained in:
CnxiFly Dev 2025-12-14 10:47:18 +01:00
parent 84d9d7d187
commit 1be3355a00
41 changed files with 8019 additions and 72 deletions

317
ANALISE_PRECIFICACAO.md Normal file
View File

@ -0,0 +1,317 @@
# Análise de Precificação de Produtos
> Documento de referência para metodologia de precificação no WEBMoney
> Versão: 1.0 | Data: 14/12/2025
---
## 1. Metodologia Atual: Markup sobre CMV
### 1.1 Fórmula Implementada
```
Markup = 1 / (1 - Deduções Totais)
Onde:
Deduções Totais = Taxa de Despesas Fixas + Custos Variáveis + Taxa de Investimento + Margem de Lucro
Preço de Venda = CMV × Markup
```
### 1.2 Componentes
| Componente | Descrição | Exemplo |
|------------|-----------|---------|
| **CMV** (Custo da Mercadoria Vendida) | Soma de todos os custos diretos do produto | €15,00 |
| **Taxa de Despesas Fixas** | Despesas Fixas ÷ Faturamento Mensal | 12% |
| **Custos Variáveis** | Impostos + Comissões + Taxa Cartão + Outros | 18% |
| **Taxa de Investimento** | Percentual para reinvestimento no negócio | 5% |
| **Margem de Lucro** | Lucro líquido desejado | 15% |
### 1.3 Exemplo Prático
```
Deduções = 12% + 18% + 5% + 15% = 50%
Markup = 1 / (1 - 0.50) = 2.00
CMV = €15,00
Preço de Venda = €15,00 × 2.00 = €30,00
Margem de Contribuição = €30,00 - €15,00 = €15,00
```
---
## 2. Análise Crítica: Vantagens e Limitações
### 2.1 ✅ Vantagens da Metodologia Atual
| Vantagem | Descrição |
|----------|-----------|
| **Simplicidade** | Fácil de entender e aplicar |
| **Cobertura de Custos** | Garante que todos os custos são considerados |
| **Consistência** | Mesma lógica para todos os produtos |
| **Margem Garantida** | Lucro definido está embutido no preço |
### 2.2 ⚠️ Limitações
| Limitação | Impacto | Solução Proposta |
|-----------|---------|------------------|
| **Não considera concorrência** | Preço pode ficar acima ou abaixo do mercado | Adicionar campo "preço de referência" |
| **Markup fixo por categoria** | Produtos premium usam mesmo markup que básicos | Permitir markup por produto |
| **Não considera elasticidade** | Ignora sensibilidade do cliente ao preço | Análise de margem por categoria |
| **CMV estático** | Não atualiza com variação de fornecedores | Histórico de custos com alertas |
| **Não diferencia canais** | Mesmo preço para loja física e online | Markup diferenciado por canal |
---
## 3. Metodologias Alternativas
### 3.1 Precificação Baseada em Valor (Value-Based Pricing)
**Conceito**: Preço definido pelo valor percebido pelo cliente, não pelo custo.
```
Preço = Valor Percebido pelo Cliente
Fatores:
- Benefício único do produto
- Qualidade percebida
- Posicionamento da marca
- Alternativas disponíveis
```
**Quando usar**: Produtos diferenciados, marcas premium, serviços especializados.
**Implementação sugerida**:
- Campo `premium_multiplier` no produto (1.0 a 3.0)
- `Preço Final = Preço Base × Premium Multiplier`
---
### 3.2 Precificação Competitiva (Competitive Pricing)
**Conceito**: Preço baseado nos concorrentes.
```
Preço = Preço Médio do Mercado ± Ajuste
Estratégias:
- Líder de preço: -5% a -15% abaixo da média
- Paridade: Igual à média
- Premium: +10% a +30% acima da média
```
**Implementação sugerida**:
- Campo `competitor_price` no produto
- Campo `price_strategy` (leader, parity, premium)
- Alerta quando `preço calculado` difere muito do `competitor_price`
---
### 3.3 Precificação Dinâmica (Dynamic Pricing)
**Conceito**: Preço ajustado conforme demanda, estoque, sazonalidade.
```
Preço = Preço Base × Fator Demanda × Fator Estoque × Fator Sazonal
Exemplos:
- Estoque alto → desconto automático
- Alta demanda → preço premium
- Baixa temporada → promoção
```
**Implementação sugerida** (futuro):
- Integração com sistema de estoque
- Regras de ajuste automático
- Dashboard de performance por produto
---
### 3.4 Precificação por Margem de Contribuição
**Conceito**: Foco na margem absoluta, não no percentual.
```
Margem de Contribuição = Preço - Custos Variáveis
Ponto de Equilíbrio = Custos Fixos ÷ Margem de Contribuição Média
Análise:
- Quantas unidades preciso vender para cobrir custos fixos?
- Qual produto gera mais margem absoluta?
```
**Vantagem**: Permite decisões mais estratégicas sobre mix de produtos.
---
## 4. Recomendação: Modelo Híbrido
### 4.1 Abordagem Recomendada
Combinar **Markup sobre CMV** (base) com **ajustes estratégicos**:
```
┌─────────────────────────────────────────────────────────────┐
│ PREÇO FINAL │
├─────────────────────────────────────────────────────────────┤
│ │
│ Preço Base = CMV × Markup (atual) │
│ ↓ │
│ Ajuste Premium = Preço Base × Premium Multiplier │
│ ↓ │
│ Validação = Comparar com Preço Concorrência │
│ ↓ │
│ Arredondamento = Preço Psicológico (ex: €29,99) │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 Campos Adicionais Sugeridos para Produto
| Campo | Tipo | Descrição |
|-------|------|-----------|
| `competitor_price` | decimal | Preço médio da concorrência |
| `min_price` | decimal | Preço mínimo aceitável (não vender abaixo) |
| `max_price` | decimal | Preço máximo (teto do mercado) |
| `premium_multiplier` | decimal | Fator de ajuste premium (1.0 - 3.0) |
| `price_strategy` | enum | 'cost_plus', 'competitive', 'premium', 'penetration' |
| `psychological_rounding` | boolean | Arredondar para .99, .90, etc |
| `target_margin_percent` | decimal | Margem desejada específica do produto |
---
## 5. Evolução Proposta: Registro Completo de Produtos
### 5.1 Estrutura do Cadastro de Produtos
```
┌─────────────────────────────────────────────────────────────┐
│ PRODUTO │
├─────────────────────────────────────────────────────────────┤
│ Informações Básicas │
│ - Nome, SKU, Código de Barras │
│ - Categoria, Subcategoria │
│ - Unidade de medida (un, kg, m, etc) │
│ - Foto/Imagem │
├─────────────────────────────────────────────────────────────┤
│ Custos (CMV) │
│ - Custo do produto (matéria-prima) │
│ - Embalagem │
│ - Etiqueta │
│ - Frete de entrada │
│ - Manuseio/Montagem │
│ - Outros custos diretos │
├─────────────────────────────────────────────────────────────┤
│ Precificação │
│ - Configuração de Markup vinculada │
│ - Preço calculado (automático) │
│ - Preço de venda (pode ser override manual) │
│ - Preço concorrência (referência) │
│ - Estratégia de preço │
│ - Multiplicador premium │
├─────────────────────────────────────────────────────────────┤
│ Estoque (futuro) │
│ - Quantidade em estoque │
│ - Estoque mínimo │
│ - Localização │
├─────────────────────────────────────────────────────────────┤
│ Fornecedores (futuro) │
│ - Lista de fornecedores │
│ - Histórico de preços de compra │
│ - Lead time de reposição │
└─────────────────────────────────────────────────────────────┘
```
### 5.2 Fluxo de Trabalho Proposto
```
1. CADASTRAR PRODUTO
├── Informações básicas
├── Vincular a uma Configuração de Markup
└── Adicionar componentes de custo (CMV)
2. CÁLCULO AUTOMÁTICO
├── Sistema calcula CMV total
├── Aplica Markup da configuração
├── Sugere preço de venda
└── Mostra margem de contribuição
3. AJUSTES MANUAIS (opcional)
├── Comparar com preço concorrência
├── Aplicar multiplicador premium
├── Override manual do preço
└── Arredondamento psicológico
4. ANÁLISE E RELATÓRIOS
├── Dashboard de margens por produto
├── Produtos abaixo da margem mínima
├── Alertas de variação de custo
└── Comparativo com concorrência
```
---
## 6. Roadmap de Implementação
### Fase 1: ✅ Atual (v1.28.0)
- [x] Configurações de Markup
- [x] Fichas Técnicas (CMV)
- [x] Calculadora de Preços
- [x] Multi-divisa
### Fase 2: Cadastro de Produtos (v1.29.0)
- [ ] Migrar "Fichas Técnicas" para "Produtos"
- [ ] Adicionar campos de precificação estratégica
- [ ] Preço de concorrência e alertas
- [ ] Multiplicador premium
- [ ] Arredondamento psicológico
### Fase 3: Análise e Relatórios (v1.30.0)
- [ ] Dashboard de margens
- [ ] Ranking de produtos por rentabilidade
- [ ] Alertas de margem mínima
- [ ] Simulador de cenários (e se o custo aumentar X%?)
### Fase 4: Integrações (futuro)
- [ ] Integração com estoque
- [ ] Histórico de custos de fornecedores
- [ ] Precificação dinâmica básica
- [ ] Exportação para e-commerce
---
## 7. Conclusão
### A metodologia atual é adequada?
**Sim, para começar.** O Markup sobre CMV é uma metodologia sólida e amplamente utilizada por:
- Pequenas e médias empresas
- Varejo em geral
- Negócios com margens estáveis
### O que recomendo adicionar?
1. **Curto prazo**: Campos de referência de concorrência e margem mínima
2. **Médio prazo**: Estratégias de preço diferenciadas por produto/categoria
3. **Longo prazo**: Precificação dinâmica e integração com estoque
### Princípio fundamental
> "O preço deve cobrir todos os custos, gerar lucro desejado, e ainda ser competitivo no mercado."
A fórmula de Markup garante os dois primeiros pontos. Os campos adicionais propostos ajudam no terceiro.
---
## Referências
- Sebrae: Formação do Preço de Venda
- Kotler, P.: Administração de Marketing
- Nagle, T.: The Strategy and Tactics of Pricing
- Harvard Business Review: Pricing Strategies
---
*Documento mantido pelo time de desenvolvimento WEBMoney*
*Última atualização: 14/12/2025*

View File

@ -5,6 +5,117 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.30.0] - 2025-12-14
### Added
- **Fichas Técnicas de Servicios** - Sistema completo de costos para servicios
- Duración del servicio en minutos
- Insumos/materiales con costos unitarios
- Cálculo automático de CSV (Costo del Servicio Vendido)
- Integración con configuración de negocio para horas productivas
- **Horas Productivas** - Cálculo de costo fijo por hora
- Nueva métrica clave: `hours_per_week` (horas por semana)
- Derivación automática: `hours_per_day = hours_per_week / days_per_week`
- Fórmula: Horas Productivas = Empleados × Horas/Día × Días/Mes × Productividad%
- Costo Fijo/Hora = Gastos Fijos / Horas Productivas
- **Seeder de Ejemplos** - `BusinessExampleSeeder` con 3 tipos de negocio
- TechStore: 5 productos (electrónicos)
- DevPro: 5 servicios (desarrollo/consultoría)
- Print & Design: 4 productos + 4 servicios (mixto)
- **Traducciones i18n Completas** - Sección business totalmente internacionalizada
- `business.common.*`: CMV, CSV, SKU, Markup, unidades de tiempo
- `business.settings.*`: totalProductiveHours, fixedCostPerHour
- `business.products.*`: skuPlaceholder, strategyLabels, components
- `business.services.*`: codePlaceholder, fixedCost, price
- Soporte: PT-BR, ES, EN
### Changed
- **Modal Tipo de Negocio** - Rediseño responsivo
- Antes: Radio buttons horizontales que excedían el ancho
- Ahora: Cards seleccionables en grid 3 columnas
- Visual mejorado con iconos grandes y bordes coloreados
### Fixed
- **Error 500 en /api/service-sheets** - Columna `hours_per_day` no existe
- Causa: Eager loading usaba columna antigua en lugar de `hours_per_week`
- Solución: Actualizado ServiceSheetController con columnas correctas
- **TypeError margin_percentage.toFixed** - Valor llegaba como string
- Solución: `parseFloat()` antes de operaciones numéricas
- **Frontend en directorio incorrecto** - Error 500 en raíz
- Causa: Archivos en /frontend/ en vez de /frontend/dist/
- Solución: Movidos archivos al directorio correcto
### Technical
- Migración: `2025_12_14_130001_change_hours_per_day_to_hours_per_week`
- Renombra `hours_per_day` a `hours_per_week`
- Agrega `working_days_per_week` (días por semana)
- Conversión automática: hours_per_week = hours_per_day * 5
- Atributo derivado en BusinessSetting: `getHoursPerDayAttribute()`
- Deploy limpio con todos los caches regenerados
## [1.29.0] - 2025-12-14
### Added
- **Campañas Promocionales** - Sistema completo de gestión de ofertas y descuentos
- **Presets Rápidos**: Black Friday, Cyber Monday, Navidad, Año Nuevo, Liquidación
- **Tipos de Descuento**: Porcentaje, Valor Fijo, Precio Fijo
- **Programación**: Fechas y horas de inicio/fin con estados automáticos
- **Badges Visuales**: Texto, color y preview en tiempo real
- **Selección de Productos**: Individual o aplicar a todos
- **Protección de Rentabilidad** - Sistema de 4 capas para evitar vender con pérdida
- Capa 1: Precio nunca menor que CMV (costo)
- Capa 2: Respeta precio mínimo del producto
- Capa 3: Respeta precio mínimo de campaña
- Capa 4: Garantiza margen mínimo sobre CMV
- Preview con análisis de rentabilidad antes de crear campaña
- **Precificación Estratégica** en Fichas Técnicas
- Precio del Competidor con comparación visual
- Precio Mínimo/Máximo para control de rango
- Multiplicador Premium (ajuste fino)
- Estrategias: Agresivo (-5%), Neutro, Premium (+10%)
- Margen Objetivo por producto (sobrescribe global)
- Redondeo Psicológico (€26.04 → €25.99)
- **Soporte B2B/B2C** - Manejo diferenciado de IVA/VAT
- **B2C**: Precios finales incluyen IVA (venta al consumidor)
- **B2B**: Precios sin IVA (venta entre empresas)
- Campo `vat_rate` separado de otros impuestos
- Preview muestra: Markup Base × (1 + IVA) = Multiplicador Final
- El IVA NO se deduce del margen, se añade al precio final
- **Documentación** - Guía completa del Módulo de Negocios
- `docs/MODULO_NEGOCIOS.txt`: Manual detallado de funcionamiento
- Casos de uso prácticos con ejemplos numéricos
- Explicación técnica del sistema de protección
### Fixed
- **Error React #310** - Corregido hook `useMemo` después de return condicional
- **Scroll en Modales** - Agregado scroll para contenido largo
- ProductSheetModal: maxHeight 90vh con overflowY auto
- BusinessSettingModal: maxHeight 90vh con overflowY auto
- **Relación BelongsToMany** - Foreign key explícita en `PromotionalCampaign.products()`
- **Traducciones Duplicadas** - Eliminadas claves repetidas en i18n
- **Claves i18n Faltantes** - Agregadas traducciones para componentes de producto
### Technical
- Nueva migración: `add_profitability_protection_to_campaigns`
- Campos: `min_margin_percent`, `protect_against_loss`, `margin_warning_threshold`
- Pivot: `promo_margin`, `promo_margin_percent`, `is_profitable`
- Nueva migración: `add_price_includes_tax_to_business_settings`
- Campos: `price_includes_tax` (boolean), `vat_rate` (decimal)
- Modelo PromotionalCampaign mejorado con métodos de protección
- Controller con análisis de rentabilidad en preview() y show()
- i18n: Traducciones completas en ES, PT-BR, EN para campañas
## [1.28.0] - 2025-12-14
### Added

View File

@ -1 +1 @@
1.28.0
1.30.0

View File

@ -40,9 +40,17 @@ public function store(Request $request): JsonResponse
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'currency' => 'required|string|size:3',
'business_type' => 'sometimes|in:products,services,both',
'employees_count' => 'sometimes|integer|min:1',
'hours_per_week' => 'sometimes|numeric|min:1|max:168',
'working_days_per_week' => 'sometimes|integer|min:1|max:7',
'working_days_per_month' => 'sometimes|integer|min:1|max:31',
'productivity_rate' => 'sometimes|numeric|min:1|max:100',
'monthly_revenue' => 'required|numeric|min:0',
'fixed_expenses' => 'required|numeric|min:0',
'tax_rate' => 'required|numeric|min:0|max:100',
'price_includes_tax' => 'sometimes|boolean',
'vat_rate' => 'sometimes|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',
@ -99,9 +107,17 @@ public function update(Request $request, $id): JsonResponse
$validator = Validator::make($request->all(), [
'name' => 'sometimes|string|max:255',
'currency' => 'sometimes|string|size:3',
'business_type' => 'sometimes|in:products,services,both',
'employees_count' => 'sometimes|integer|min:1',
'hours_per_week' => 'sometimes|numeric|min:1|max:168',
'working_days_per_week' => 'sometimes|integer|min:1|max:7',
'working_days_per_month' => 'sometimes|integer|min:1|max:31',
'productivity_rate' => 'sometimes|numeric|min:1|max:100',
'monthly_revenue' => 'sometimes|numeric|min:0',
'fixed_expenses' => 'sometimes|numeric|min:0',
'tax_rate' => 'sometimes|numeric|min:0|max:100',
'price_includes_tax' => 'sometimes|boolean',
'vat_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',

View File

@ -41,6 +41,7 @@ public function index(Request $request): JsonResponse
$sheets = $query->get()->map(function ($sheet) {
return array_merge($sheet->toArray(), [
'contribution_margin_percent' => $sheet->contribution_margin_percent,
'competitor_comparison' => $sheet->competitor_comparison,
]);
});
@ -162,6 +163,14 @@ public function update(Request $request, $id): JsonResponse
'currency' => 'sometimes|string|size:3',
'business_setting_id' => 'nullable|exists:business_settings,id',
'is_active' => 'sometimes|boolean',
// Strategic pricing fields
'competitor_price' => 'nullable|numeric|min:0',
'min_price' => 'nullable|numeric|min:0',
'max_price' => 'nullable|numeric|min:0',
'premium_multiplier' => 'nullable|numeric|min:0.1|max:5',
'price_strategy' => 'nullable|in:aggressive,neutral,premium',
'psychological_rounding' => 'nullable|boolean',
'target_margin_percent' => 'nullable|numeric|min:0|max:99',
]);
if ($validator->fails()) {
@ -183,10 +192,14 @@ public function update(Request $request, $id): JsonResponse
$sheet->calculateSalePrice();
}
// Calcular preço final com estratégias
$sheet->calculateFinalPrice();
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'contribution_margin_percent' => $sheet->contribution_margin_percent,
'competitor_comparison' => $sheet->competitor_comparison,
]));
}

View File

@ -0,0 +1,557 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PromotionalCampaign;
use App\Models\ProductSheet;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Carbon\Carbon;
class PromotionalCampaignController extends Controller
{
/**
* Lista todas as campanhas do usuário
*/
public function index(Request $request): JsonResponse
{
$userId = $request->user()->id;
$query = PromotionalCampaign::ofUser($userId)
->withCount('products');
// Filtro por status
if ($request->has('status')) {
switch ($request->status) {
case 'active':
$query->active()->current();
break;
case 'scheduled':
$query->active()->upcoming();
break;
case 'ended':
$query->where('end_date', '<', Carbon::now());
break;
case 'inactive':
$query->where('is_active', false);
break;
}
}
$campaigns = $query->orderBy('start_date', 'desc')->get()->map(function ($campaign) {
return array_merge($campaign->toArray(), [
'status' => $campaign->status,
'days_remaining' => $campaign->days_remaining,
'is_currently_active' => $campaign->isCurrentlyActive(),
]);
});
return response()->json($campaigns);
}
/**
* Cria uma nova campanha
*/
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'description' => 'nullable|string',
'discount_type' => 'required|in:percentage,fixed,price_override',
'discount_value' => 'required|numeric|min:0',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_active' => 'boolean',
'apply_to_all_products' => 'boolean',
'min_price' => 'nullable|numeric|min:0',
'show_original_price' => 'boolean',
'show_discount_badge' => 'boolean',
'badge_text' => 'nullable|string|max:50',
'badge_color' => 'nullable|string|max:20',
'priority' => 'integer|min:0',
'product_ids' => 'nullable|array',
'product_ids.*' => 'exists:product_sheets,id',
'preset' => 'nullable|string', // Para usar presets
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$userId = $request->user()->id;
$data = $validator->validated();
// Aplicar preset se fornecido
if (!empty($data['preset']) && isset(PromotionalCampaign::PRESETS[$data['preset']])) {
$preset = PromotionalCampaign::PRESETS[$data['preset']];
$data = array_merge($preset, $data);
}
unset($data['product_ids'], $data['preset']);
$data['user_id'] = $userId;
DB::beginTransaction();
try {
$campaign = PromotionalCampaign::create($data);
// Adicionar produtos se fornecidos
if ($request->has('product_ids') && is_array($request->product_ids)) {
foreach ($request->product_ids as $productId) {
$product = ProductSheet::ofUser($userId)->find($productId);
if ($product) {
$promoPrice = $campaign->calculatePromotionalPrice($product);
$campaign->products()->attach($productId, [
'promotional_price' => $promoPrice,
]);
}
}
}
// Se apply_to_all_products, adicionar todos
if ($campaign->apply_to_all_products) {
$campaign->applyToAllProducts();
}
DB::commit();
$campaign->load('products');
return response()->json(array_merge($campaign->toArray(), [
'status' => $campaign->status,
'products_count' => $campaign->products->count(),
]), 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Erro ao criar campanha: ' . $e->getMessage()], 500);
}
}
/**
* Exibe uma campanha específica
*/
public function show(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)
->with(['products' => function ($query) {
$query->select('product_sheets.*')
->withPivot(['discount_type', 'discount_value', 'promotional_price', 'promo_margin', 'promo_margin_percent', 'is_profitable']);
}])
->findOrFail($id);
// Adicionar informações de cada produto COM RENTABILIDADE
$totalProfit = 0;
$unprofitableCount = 0;
$productsWithInfo = $campaign->products->map(function ($product) use ($campaign, &$totalProfit, &$unprofitableCount) {
$originalPrice = (float) ($product->final_price ?? $product->sale_price);
$promoPrice = (float) $product->pivot->promotional_price;
$cmv = (float) ($product->cmv_total ?? 0);
// Calcular rentabilidade
$marginInfo = $campaign->getPromotionalMargin($product);
if (!$marginInfo['is_profitable']) {
$unprofitableCount++;
}
$totalProfit += $marginInfo['promo_margin'];
return array_merge($product->toArray(), [
'original_price' => $originalPrice,
'promotional_price' => $promoPrice,
'cmv' => $cmv,
'discount_percent' => $campaign->getDiscountPercentage($originalPrice, $promoPrice),
'savings' => round($originalPrice - $promoPrice, 2),
'badge' => $campaign->getBadgeInfo($originalPrice, $promoPrice),
// Dados de rentabilidade
'promo_margin' => $marginInfo['promo_margin'],
'promo_margin_percent' => $marginInfo['promo_margin_percent'],
'original_margin' => $marginInfo['original_margin'],
'original_margin_percent' => $marginInfo['original_margin_percent'],
'margin_reduction' => $marginInfo['margin_reduction'],
'is_profitable' => $marginInfo['is_profitable'],
'is_protected' => $marginInfo['is_protected'],
]);
});
return response()->json(array_merge($campaign->toArray(), [
'status' => $campaign->status,
'days_remaining' => $campaign->days_remaining,
'is_currently_active' => $campaign->isCurrentlyActive(),
'products' => $productsWithInfo,
// Resumo de rentabilidade
'profitability_summary' => [
'total_profit' => round($totalProfit, 2),
'unprofitable_count' => $unprofitableCount,
'all_profitable' => $unprofitableCount === 0,
],
]));
}
/**
* Atualiza uma campanha
*/
public function update(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'name' => 'sometimes|string|max:255',
'code' => 'nullable|string|max:50',
'description' => 'nullable|string',
'discount_type' => 'sometimes|in:percentage,fixed,price_override',
'discount_value' => 'sometimes|numeric|min:0',
'start_date' => 'sometimes|date',
'end_date' => 'sometimes|date|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_active' => 'boolean',
'apply_to_all_products' => 'boolean',
'min_price' => 'nullable|numeric|min:0',
'show_original_price' => 'boolean',
'show_discount_badge' => 'boolean',
'badge_text' => 'nullable|string|max:50',
'badge_color' => 'nullable|string|max:20',
'priority' => 'integer|min:0',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$campaign->update($validator->validated());
// Recalcular preços se desconto mudou
if ($request->hasAny(['discount_type', 'discount_value', 'min_price'])) {
$campaign->recalculateAllPrices();
}
// Se mudou para apply_to_all_products
if ($request->has('apply_to_all_products') && $campaign->apply_to_all_products) {
$campaign->applyToAllProducts();
}
$campaign->load('products');
return response()->json(array_merge($campaign->toArray(), [
'status' => $campaign->status,
'products_count' => $campaign->products->count(),
]));
}
/**
* Remove uma campanha
*/
public function destroy(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$campaign->delete();
return response()->json(['message' => 'Campanha excluída com sucesso']);
}
/**
* Adiciona produtos à campanha
*/
public function addProducts(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'product_ids' => 'required|array',
'product_ids.*' => 'exists:product_sheets,id',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$userId = $request->user()->id;
$added = 0;
foreach ($request->product_ids as $productId) {
$product = ProductSheet::ofUser($userId)->find($productId);
if ($product && !$campaign->products->contains($productId)) {
$promoPrice = $campaign->calculatePromotionalPrice($product);
$campaign->products()->attach($productId, [
'promotional_price' => $promoPrice,
]);
$added++;
}
}
return response()->json([
'message' => "{$added} produto(s) adicionado(s) à campanha",
'products_count' => $campaign->products()->count(),
]);
}
/**
* Remove produtos da campanha
*/
public function removeProducts(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'product_ids' => 'required|array',
'product_ids.*' => 'exists:product_sheets,id',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$campaign->products()->detach($request->product_ids);
return response()->json([
'message' => 'Produto(s) removido(s) da campanha',
'products_count' => $campaign->products()->count(),
]);
}
/**
* Atualiza desconto específico de um produto na campanha
*/
public function updateProductDiscount(Request $request, $campaignId, $productId): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($campaignId);
$validator = Validator::make($request->all(), [
'discount_type' => 'nullable|in:percentage,fixed,price_override',
'discount_value' => 'nullable|numeric|min:0',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$product = $campaign->products()->where('product_sheet_id', $productId)->first();
if (!$product) {
return response()->json(['message' => 'Produto não encontrado na campanha'], 404);
}
$override = null;
if ($request->discount_type) {
$override = [
'discount_type' => $request->discount_type,
'discount_value' => $request->discount_value,
];
}
$promoPrice = $campaign->calculatePromotionalPrice($product, $override);
$campaign->products()->updateExistingPivot($productId, [
'discount_type' => $request->discount_type,
'discount_value' => $request->discount_value,
'promotional_price' => $promoPrice,
]);
return response()->json([
'promotional_price' => $promoPrice,
'message' => 'Desconto atualizado',
]);
}
/**
* Retorna presets disponíveis
*/
public function presets(): JsonResponse
{
return response()->json(PromotionalCampaign::getPresets());
}
/**
* Duplica uma campanha
*/
public function duplicate(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)
->with('products')
->findOrFail($id);
DB::beginTransaction();
try {
$newCampaign = $campaign->replicate();
$newCampaign->name = $campaign->name . ' (Cópia)';
$newCampaign->is_active = false;
$newCampaign->start_date = Carbon::now();
$newCampaign->end_date = Carbon::now()->addDays(7);
$newCampaign->save();
// Copiar produtos
foreach ($campaign->products as $product) {
$newCampaign->products()->attach($product->id, [
'discount_type' => $product->pivot->discount_type,
'discount_value' => $product->pivot->discount_value,
'promotional_price' => $product->pivot->promotional_price,
]);
}
DB::commit();
return response()->json(array_merge($newCampaign->toArray(), [
'status' => $newCampaign->status,
'products_count' => $newCampaign->products()->count(),
]), 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Erro ao duplicar: ' . $e->getMessage()], 500);
}
}
/**
* Preview de preços para uma campanha COM ANÁLISE DE RENTABILIDADE
*/
public function preview(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'discount_type' => 'required|in:percentage,fixed,price_override',
'discount_value' => 'required|numeric|min:0',
'min_price' => 'nullable|numeric|min:0',
'min_margin_percent' => 'nullable|numeric|min:0',
'protect_against_loss' => 'nullable|boolean',
'product_ids' => 'required|array|min:1',
'product_ids.*' => 'exists:product_sheets,id',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$userId = $request->user()->id;
$products = ProductSheet::ofUser($userId)
->whereIn('id', $request->product_ids)
->get();
$protectAgainstLoss = $request->protect_against_loss !== false; // default true
$minMarginPercent = $request->min_margin_percent;
$unprofitableCount = 0;
$protectedCount = 0;
$preview = $products->map(function ($product) use ($request, $protectAgainstLoss, $minMarginPercent, &$unprofitableCount, &$protectedCount) {
$originalPrice = (float) ($product->final_price ?? $product->sale_price);
$cmv = (float) ($product->cmv_total ?? 0);
$promoPrice = $originalPrice;
// Calcular desconto
switch ($request->discount_type) {
case 'percentage':
$promoPrice = $originalPrice * (1 - ($request->discount_value / 100));
break;
case 'fixed':
$promoPrice = $originalPrice - $request->discount_value;
break;
case 'price_override':
$promoPrice = $request->discount_value;
break;
}
$wasProtected = false;
$protectionReason = null;
// Aplicar preço mínimo da campanha
if ($request->min_price && $promoPrice < $request->min_price) {
$promoPrice = $request->min_price;
$wasProtected = true;
$protectionReason = 'campaign_min_price';
}
// Aplicar preço mínimo do produto
if ($product->min_price && $promoPrice < (float) $product->min_price) {
$promoPrice = (float) $product->min_price;
$wasProtected = true;
$protectionReason = 'product_min_price';
}
// Proteção contra prejuízo (nunca vender abaixo do CMV)
if ($protectAgainstLoss && $cmv > 0 && $promoPrice < $cmv) {
$promoPrice = $cmv;
$wasProtected = true;
$protectionReason = 'cmv_protection';
$protectedCount++;
}
// Margem mínima obrigatória
if ($minMarginPercent && $cmv > 0) {
$minPriceWithMargin = $cmv * (1 + ($minMarginPercent / 100));
if ($promoPrice < $minPriceWithMargin) {
$promoPrice = $minPriceWithMargin;
$wasProtected = true;
$protectionReason = 'min_margin';
}
}
$promoPrice = max(0, round($promoPrice, 2));
// Calcular margens
$discountPercent = $originalPrice > 0
? round((($originalPrice - $promoPrice) / $originalPrice) * 100, 1)
: 0;
$promoMargin = $promoPrice - $cmv;
$promoMarginPercent = $promoPrice > 0 ? round(($promoMargin / $promoPrice) * 100, 1) : 0;
$originalMargin = $originalPrice - $cmv;
$originalMarginPercent = $originalPrice > 0 ? round(($originalMargin / $originalPrice) * 100, 1) : 0;
$isProfitable = $promoMargin > 0;
if (!$isProfitable) {
$unprofitableCount++;
}
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'cmv' => $cmv,
'original_price' => $originalPrice,
'promotional_price' => $promoPrice,
'discount_percent' => $discountPercent,
'savings' => round($originalPrice - $promoPrice, 2),
// Rentabilidade
'promo_margin' => round($promoMargin, 2),
'promo_margin_percent' => $promoMarginPercent,
'original_margin' => round($originalMargin, 2),
'original_margin_percent' => $originalMarginPercent,
'margin_reduction' => round($originalMarginPercent - $promoMarginPercent, 1),
'is_profitable' => $isProfitable,
// Proteção
'was_protected' => $wasProtected,
'protection_reason' => $protectionReason,
];
});
$totals = [
'products_count' => $preview->count(),
'total_original' => round($preview->sum('original_price'), 2),
'total_promotional' => round($preview->sum('promotional_price'), 2),
'total_savings' => round($preview->sum('savings'), 2),
'avg_discount' => round($preview->avg('discount_percent'), 1),
// Rentabilidade
'total_cmv' => round($preview->sum('cmv'), 2),
'total_promo_margin' => round($preview->sum('promo_margin'), 2),
'avg_promo_margin_percent' => round($preview->avg('promo_margin_percent'), 1),
'avg_original_margin_percent' => round($preview->avg('original_margin_percent'), 1),
// Alertas
'unprofitable_count' => $unprofitableCount,
'protected_count' => $protectedCount,
'all_profitable' => $unprofitableCount === 0,
];
return response()->json([
'products' => $preview,
'totals' => $totals,
]);
}
}

View File

@ -0,0 +1,431 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ServiceSheet;
use App\Models\ServiceSheetItem;
use App\Models\BusinessSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class ServiceSheetController extends Controller
{
/**
* Lista todas as fichas de serviço do usuário
*/
public function index(Request $request): JsonResponse
{
$userId = $request->user()->id;
$query = ServiceSheet::ofUser($userId)
->with(['items', 'businessSetting:id,name,currency,markup_factor,fixed_expenses,employees_count,hours_per_week,working_days_per_week,working_days_per_month,productivity_rate']);
// Filtro por categoria
if ($request->has('category')) {
$query->where('category', $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(), [
'cost_breakdown' => $sheet->cost_breakdown,
]);
});
return response()->json($sheets);
}
/**
* Cria uma nova ficha de serviço
*/
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'business_setting_id' => 'required|exists:business_settings,id',
'duration_minutes' => 'required|numeric|min:1',
'items' => 'nullable|array',
'items.*.name' => 'required|string|max:255',
'items.*.type' => 'required|in:supply,consumable,material,equipment_usage,other',
'items.*.unit_cost' => 'required|numeric|min:0',
'items.*.quantity_used' => 'nullable|numeric|min:0',
'items.*.unit' => 'nullable|string|max:20',
// Strategic pricing fields
'competitor_price' => 'nullable|numeric|min:0',
'min_price' => 'nullable|numeric|min:0',
'max_price' => 'nullable|numeric|min:0',
'premium_multiplier' => 'nullable|numeric|min:0.1|max:5',
'pricing_strategy' => 'nullable|in:aggressive,neutral,premium',
'psychological_pricing' => 'nullable|boolean',
'target_margin' => 'nullable|numeric|min:0|max:99',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$userId = $request->user()->id;
// Verificar se business_setting_id pertence ao usuário
$setting = BusinessSetting::ofUser($userId)->find($request->business_setting_id);
if (!$setting) {
return response()->json(['message' => 'Configuração de negócio não encontrada'], 404);
}
// Verificar se a configuração suporta serviços
if ($setting->business_type === 'products') {
return response()->json(['message' => 'Esta configuração é apenas para produtos. Altere para "Serviços" ou "Ambos".'], 422);
}
DB::beginTransaction();
try {
// Criar a ficha de serviço
$sheet = ServiceSheet::create([
'user_id' => $userId,
'business_setting_id' => $request->business_setting_id,
'name' => $request->name,
'code' => $request->code,
'description' => $request->description,
'category' => $request->category,
'duration_minutes' => $request->duration_minutes,
'competitor_price' => $request->competitor_price,
'min_price' => $request->min_price,
'max_price' => $request->max_price,
'premium_multiplier' => $request->premium_multiplier ?? 1,
'pricing_strategy' => $request->pricing_strategy ?? 'neutral',
'psychological_pricing' => $request->psychological_pricing ?? false,
'target_margin' => $request->target_margin,
]);
// Criar os itens (insumos)
if ($request->has('items') && is_array($request->items)) {
foreach ($request->items as $itemData) {
ServiceSheetItem::create([
'service_sheet_id' => $sheet->id,
'name' => $itemData['name'],
'type' => $itemData['type'],
'unit_cost' => $itemData['unit_cost'],
'quantity_used' => $itemData['quantity_used'] ?? 1,
'unit' => $itemData['unit'] ?? null,
'notes' => $itemData['notes'] ?? null,
]);
}
}
// Recalcular tudo
$sheet->recalculate();
DB::commit();
// Recarregar com relacionamentos
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'cost_breakdown' => $sheet->cost_breakdown,
]), 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Erro ao criar ficha de serviço: ' . $e->getMessage()], 500);
}
}
/**
* Exibe uma ficha de serviço específica
*/
public function show(Request $request, $id): JsonResponse
{
$sheet = ServiceSheet::ofUser($request->user()->id)
->with(['items', 'businessSetting'])
->findOrFail($id);
return response()->json(array_merge($sheet->toArray(), [
'cost_breakdown' => $sheet->cost_breakdown,
]));
}
/**
* Atualiza uma ficha de serviço
*/
public function update(Request $request, $id): JsonResponse
{
$sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'name' => 'sometimes|string|max:255',
'code' => 'nullable|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'business_setting_id' => 'sometimes|exists:business_settings,id',
'duration_minutes' => 'sometimes|numeric|min:1',
'is_active' => 'sometimes|boolean',
// Strategic pricing fields
'competitor_price' => 'nullable|numeric|min:0',
'min_price' => 'nullable|numeric|min:0',
'max_price' => 'nullable|numeric|min:0',
'premium_multiplier' => 'nullable|numeric|min:0.1|max:5',
'pricing_strategy' => 'nullable|in:aggressive,neutral,premium',
'psychological_pricing' => 'nullable|boolean',
'target_margin' => 'nullable|numeric|min:0|max:99',
]);
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);
}
// Verificar se suporta serviços
if ($setting->business_type === 'products') {
return response()->json(['message' => 'Esta configuração é apenas para produtos.'], 422);
}
}
$sheet->update($validator->validated());
// Recalcular preços
$sheet->recalculate();
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'cost_breakdown' => $sheet->cost_breakdown,
]));
}
/**
* Remove uma ficha de serviço
*/
public function destroy(Request $request, $id): JsonResponse
{
$sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($id);
$sheet->delete();
return response()->json(['message' => 'Ficha de serviço excluída com sucesso']);
}
/**
* Adiciona um item à ficha de serviço
*/
public function addItem(Request $request, $id): JsonResponse
{
$sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'type' => 'required|in:supply,consumable,material,equipment_usage,other',
'unit_cost' => 'required|numeric|min:0',
'quantity_used' => 'nullable|numeric|min:0',
'unit' => 'nullable|string|max:20',
'notes' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
ServiceSheetItem::create([
'service_sheet_id' => $sheet->id,
'name' => $request->name,
'type' => $request->type,
'unit_cost' => $request->unit_cost,
'quantity_used' => $request->quantity_used ?? 1,
'unit' => $request->unit,
'notes' => $request->notes,
]);
// Recalcular preço
$sheet->recalculate();
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'cost_breakdown' => $sheet->cost_breakdown,
]));
}
/**
* Atualiza um item da ficha de serviço
*/
public function updateItem(Request $request, $sheetId, $itemId): JsonResponse
{
$sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($sheetId);
$item = ServiceSheetItem::where('service_sheet_id', $sheet->id)->findOrFail($itemId);
$validator = Validator::make($request->all(), [
'name' => 'sometimes|string|max:255',
'type' => 'sometimes|in:supply,consumable,material,equipment_usage,other',
'unit_cost' => 'sometimes|numeric|min:0',
'quantity_used' => 'sometimes|numeric|min:0',
'unit' => 'nullable|string|max:20',
'notes' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$item->update($validator->validated());
// Recalcular preço
$sheet->recalculate();
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'cost_breakdown' => $sheet->cost_breakdown,
]));
}
/**
* Remove um item da ficha de serviço
*/
public function removeItem(Request $request, $sheetId, $itemId): JsonResponse
{
$sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($sheetId);
$item = ServiceSheetItem::where('service_sheet_id', $sheet->id)->findOrFail($itemId);
$item->delete();
// Recalcular preço
$sheet->recalculate();
$sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($sheet->toArray(), [
'cost_breakdown' => $sheet->cost_breakdown,
]));
}
/**
* Lista as categorias de serviços do usuário
*/
public function categories(Request $request): JsonResponse
{
$categories = ServiceSheet::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(ServiceSheetItem::TYPES);
}
/**
* Duplica uma ficha de serviço
*/
public function duplicate(Request $request, $id): JsonResponse
{
$sheet = ServiceSheet::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->service_sheet_id = $newSheet->id;
$newItem->save();
}
// Recalcular
$newSheet->recalculate();
DB::commit();
$newSheet->load(['items', 'businessSetting:id,name,currency,markup_factor']);
return response()->json(array_merge($newSheet->toArray(), [
'cost_breakdown' => $newSheet->cost_breakdown,
]), 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Erro ao duplicar ficha de serviço: ' . $e->getMessage()], 500);
}
}
/**
* Simula o preço de um serviço (para calculadora)
*/
public function simulate(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'business_setting_id' => 'required|exists:business_settings,id',
'duration_minutes' => 'required|numeric|min:1',
'csv' => 'required|numeric|min:0',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$setting = BusinessSetting::ofUser($request->user()->id)->findOrFail($request->business_setting_id);
$price = $setting->calculateServicePrice(
(float) $request->duration_minutes,
(float) $request->csv
);
// Calcular breakdown
$fixedCostPortion = $setting->fixed_cost_per_minute * $request->duration_minutes;
$baseCost = $fixedCostPortion + $request->csv;
$markup = $setting->markup_factor ?? $setting->calculateMarkup();
$priceWithoutVat = $baseCost * $markup;
$vatAmount = 0;
if ($setting->price_includes_tax) {
$vatAmount = $price - $priceWithoutVat;
}
return response()->json([
'duration_minutes' => (float) $request->duration_minutes,
'duration_hours' => round($request->duration_minutes / 60, 2),
'csv' => (float) $request->csv,
'fixed_cost_per_hour' => $setting->fixed_cost_per_hour,
'fixed_cost_portion' => round($fixedCostPortion, 2),
'base_cost' => round($baseCost, 2),
'markup' => $markup,
'price_without_vat' => round($priceWithoutVat, 2),
'vat_rate' => $setting->price_includes_tax ? (float) $setting->vat_rate : 0,
'vat_amount' => round($vatAmount, 2),
'final_price' => $price,
'productive_hours' => $setting->productive_hours,
]);
}
}

View File

@ -15,9 +15,17 @@ class BusinessSetting extends Model
'user_id',
'currency',
'name',
'business_type',
'employees_count',
'hours_per_week',
'working_days_per_week',
'working_days_per_month',
'productivity_rate',
'monthly_revenue',
'fixed_expenses',
'tax_rate',
'price_includes_tax',
'vat_rate',
'sales_commission',
'card_fee',
'other_variable_costs',
@ -28,9 +36,17 @@ class BusinessSetting extends Model
];
protected $casts = [
'business_type' => 'string',
'employees_count' => 'integer',
'hours_per_week' => 'decimal:2',
'working_days_per_week' => 'integer',
'working_days_per_month' => 'integer',
'productivity_rate' => 'decimal:2',
'monthly_revenue' => 'decimal:2',
'fixed_expenses' => 'decimal:2',
'tax_rate' => 'decimal:2',
'price_includes_tax' => 'boolean',
'vat_rate' => 'decimal:2',
'sales_commission' => 'decimal:2',
'card_fee' => 'decimal:2',
'other_variable_costs' => 'decimal:2',
@ -40,6 +56,23 @@ class BusinessSetting extends Model
'is_active' => 'boolean',
];
/**
* Derived attributes - calculated from hours_per_week
*/
protected $appends = ['hours_per_day', 'productive_hours', 'fixed_cost_per_hour'];
/**
* Calcula horas por dia a partir de horas por semana
* hours_per_day = hours_per_week / working_days_per_week
*/
public function getHoursPerDayAttribute(): float
{
$hoursPerWeek = (float) ($this->hours_per_week ?? 40);
$daysPerWeek = (int) ($this->working_days_per_week ?? 5);
return $daysPerWeek > 0 ? round($hoursPerWeek / $daysPerWeek, 2) : 8;
}
/**
* Relacionamento com usuário
*/
@ -49,13 +82,107 @@ public function user(): BelongsTo
}
/**
* Fichas técnicas que usam esta configuração
* Fichas técnicas de produtos que usam esta configuração
*/
public function productSheets(): HasMany
{
return $this->hasMany(ProductSheet::class);
}
/**
* Fichas técnicas de serviços que usam esta configuração
*/
public function serviceSheets(): HasMany
{
return $this->hasMany(ServiceSheet::class);
}
/**
* Calcula as horas produtivas mensais
*
* Formula baseada em horas por semana:
* Horas Produtivas = Funcionários × (Horas/Semana × 4.33 semanas/mês) × Produtividade%
*
* Ou usando dias do mês:
* Horas Produtivas = Funcionários × (Horas/Semana / Dias/Semana × Dias/Mês) × Produtividade%
*
* @return float
*/
public function getProductiveHoursAttribute(): float
{
$employees = (int) ($this->employees_count ?? 1);
$hoursPerWeek = (float) ($this->hours_per_week ?? 40);
$daysPerWeek = (int) ($this->working_days_per_week ?? 5);
$daysPerMonth = (int) ($this->working_days_per_month ?? 22);
$productivity = (float) ($this->productivity_rate ?? 80) / 100;
// Calcula horas por dia a partir de horas por semana
$hoursPerDay = $daysPerWeek > 0 ? $hoursPerWeek / $daysPerWeek : 8;
// Horas produtivas = funcionários × horas/dia × dias/mês × produtividade
return round($employees * $hoursPerDay * $daysPerMonth * $productivity, 2);
}
/**
* Calcula o gasto fixo por hora
* Gasto Fixo/Hora = Despesas Fixas / Horas Produtivas
*
* @return float
*/
public function getFixedCostPerHourAttribute(): float
{
$productiveHours = $this->productive_hours;
if ($productiveHours <= 0) {
return 0;
}
return round((float) $this->fixed_expenses / $productiveHours, 2);
}
/**
* Calcula o gasto fixo por minuto
*
* @return float
*/
public function getFixedCostPerMinuteAttribute(): float
{
return round($this->fixed_cost_per_hour / 60, 4);
}
/**
* Calcula o preço de venda de um serviço
* Preço = (Gasto Fixo/Hora × Duração em Horas + CSV) × Markup
*
* Para B2C, ainda adiciona o IVA no final
*
* @param float $durationMinutes Duração do serviço em minutos
* @param float $csv Custo do Serviço Vendido (insumos)
* @return float
*/
public function calculateServicePrice(float $durationMinutes, float $csv): float
{
$markup = $this->markup_factor ?? $this->calculateMarkup();
if ($markup <= 0) {
return 0;
}
// Gasto fixo proporcional ao tempo do serviço
$fixedCostPortion = $this->fixed_cost_per_minute * $durationMinutes;
// Preço base = (Gasto Fixo + CSV) × Markup
$priceWithoutTax = ($fixedCostPortion + $csv) * $markup;
// Se B2C, adiciona IVA
if ($this->price_includes_tax) {
$vatRate = (float) ($this->vat_rate ?? 0);
return round($priceWithoutTax * (1 + $vatRate / 100), 2);
}
return round($priceWithoutTax, 2);
}
/**
* Calcula o percentual de despesas fixas sobre a receita
*
@ -72,15 +199,23 @@ public function getFixedExpensesRateAttribute(): float
/**
* Calcula o total de custos variáveis (sem CMV)
* Para B2C (price_includes_tax=true), o tax_rate NÃO entra aqui pois é adicionado ao final
* Para B2B (price_includes_tax=false), o tax_rate é uma dedução do preço
*
* @return float
*/
public function getTotalVariableCostsAttribute(): float
{
return $this->tax_rate +
$this->sales_commission +
$costs = $this->sales_commission +
$this->card_fee +
$this->other_variable_costs;
// Se B2B (preço NÃO inclui imposto), adiciona tax_rate como custo
if (!$this->price_includes_tax) {
$costs += $this->tax_rate;
}
return $costs;
}
/**
@ -127,6 +262,9 @@ public function recalculateMarkup(): float
/**
* Calcula o preço de venda para um CMV dado
*
* B2B (price_includes_tax=false): Preço = CMV × Markup (imposto está nas deduções)
* B2C (price_includes_tax=true): Preço = CMV × Markup × (1 + VAT%) (imposto adicionado ao final)
*
* @param float $cmv Custo da Mercadoria Vendida
* @return float
*/
@ -138,9 +276,56 @@ public function calculateSalePrice(float $cmv): float
return 0;
}
$priceWithoutTax = $cmv * $markup;
// Se B2C (preço inclui imposto), adiciona o IVA/VAT ao preço final
if ($this->price_includes_tax) {
$vatRate = (float) ($this->vat_rate ?? 0);
return round($priceWithoutTax * (1 + $vatRate / 100), 2);
}
return round($priceWithoutTax, 2);
}
/**
* Calcula o preço SEM impostos para um CMV dado
* Útil para mostrar o preço base antes do IVA
*
* @param float $cmv Custo da Mercadoria Vendida
* @return float
*/
public function calculatePriceWithoutTax(float $cmv): float
{
$markup = $this->markup_factor ?? $this->calculateMarkup();
if ($markup <= 0) {
return 0;
}
return round($cmv * $markup, 2);
}
/**
* Extrai o valor do IVA de um preço final (B2C)
*
* @param float $finalPrice Preço final com IVA
* @return float
*/
public function extractVat(float $finalPrice): float
{
if (!$this->price_includes_tax) {
return 0;
}
$vatRate = (float) ($this->vat_rate ?? 0);
if ($vatRate <= 0) {
return 0;
}
$priceWithoutVat = $finalPrice / (1 + $vatRate / 100);
return round($finalPrice - $priceWithoutVat, 2);
}
/**
* Retorna o breakdown do Markup para exibição
*
@ -151,6 +336,8 @@ public function getMarkupBreakdownAttribute(): array
return [
'fixed_expenses_rate' => $this->fixed_expenses_rate,
'tax_rate' => (float) $this->tax_rate,
'price_includes_tax' => (bool) $this->price_includes_tax,
'vat_rate' => (float) ($this->vat_rate ?? 21),
'sales_commission' => (float) $this->sales_commission,
'card_fee' => (float) $this->card_fee,
'other_variable_costs' => (float) $this->other_variable_costs,

View File

@ -24,6 +24,16 @@ class ProductSheet extends Model
'markup_used',
'contribution_margin',
'is_active',
// Strategic pricing fields
'competitor_price',
'min_price',
'max_price',
'premium_multiplier',
'price_strategy',
'psychological_rounding',
'target_margin_percent',
'final_price',
'real_margin_percent',
];
protected $casts = [
@ -32,8 +42,22 @@ class ProductSheet extends Model
'markup_used' => 'decimal:4',
'contribution_margin' => 'decimal:2',
'is_active' => 'boolean',
// Strategic pricing casts
'competitor_price' => 'decimal:2',
'min_price' => 'decimal:2',
'max_price' => 'decimal:2',
'premium_multiplier' => 'decimal:2',
'psychological_rounding' => 'boolean',
'target_margin_percent' => 'decimal:2',
'final_price' => 'decimal:2',
'real_margin_percent' => 'decimal:2',
];
// Price strategies
const STRATEGY_AGGRESSIVE = 'aggressive';
const STRATEGY_NEUTRAL = 'neutral';
const STRATEGY_PREMIUM = 'premium';
/**
* Relacionamento com usuário
*/
@ -136,4 +160,168 @@ public function scopeByCategory($query, $category)
{
return $query->where('category', $category);
}
/**
* Calcula o preço final aplicando estratégias de precificação
*
* @return float
*/
public function calculateFinalPrice(): float
{
// Começar com o preço base (markup)
$basePrice = (float) $this->sale_price;
if ($basePrice <= 0) {
return 0;
}
$finalPrice = $basePrice;
// 1. Aplicar multiplicador premium
$premiumMultiplier = (float) ($this->premium_multiplier ?? 1.0);
$finalPrice = $finalPrice * $premiumMultiplier;
// 2. Aplicar estratégia de preço baseada no concorrente
if ($this->competitor_price && $this->competitor_price > 0) {
$competitorPrice = (float) $this->competitor_price;
switch ($this->price_strategy) {
case self::STRATEGY_AGGRESSIVE:
// 5% abaixo do concorrente
$targetPrice = $competitorPrice * 0.95;
$finalPrice = min($finalPrice, $targetPrice);
break;
case self::STRATEGY_PREMIUM:
// 10% acima do concorrente
$targetPrice = $competitorPrice * 1.10;
$finalPrice = max($finalPrice, $targetPrice);
break;
case self::STRATEGY_NEUTRAL:
default:
// Manter preço calculado, mas não muito diferente do concorrente
break;
}
}
// 3. Aplicar margem alvo específica se definida
if ($this->target_margin_percent !== null && $this->target_margin_percent > 0) {
$targetMargin = (float) $this->target_margin_percent / 100;
$cmv = (float) $this->cmv_total;
// Preço = CMV / (1 - margem)
$targetPrice = $cmv / (1 - $targetMargin);
$finalPrice = $targetPrice;
}
// 4. Aplicar limites min/max
if ($this->min_price && $finalPrice < $this->min_price) {
$finalPrice = (float) $this->min_price;
}
if ($this->max_price && $finalPrice > $this->max_price) {
$finalPrice = (float) $this->max_price;
}
// 5. Aplicar arredondamento psicológico
if ($this->psychological_rounding) {
$finalPrice = $this->applyPsychologicalRounding($finalPrice);
}
// Salvar resultado
$this->final_price = round($finalPrice, 2);
$this->real_margin_percent = $this->calculateRealMargin($this->final_price);
$this->save();
return $this->final_price;
}
/**
* Aplica arredondamento psicológico ao preço
* Ex: 26.04 -> 25.99, 135.41 -> 134.99
*
* @param float $price
* @return float
*/
protected function applyPsychologicalRounding(float $price): float
{
if ($price < 10) {
// Preços baixos: arredondar para .99
return floor($price) + 0.99;
} elseif ($price < 100) {
// Preços médios: arredondar para X9.99
$tens = floor($price / 10) * 10;
$remainder = $price - $tens;
if ($remainder >= 5) {
return $tens + 9.99;
} else {
return $tens - 0.01;
}
} else {
// Preços altos: arredondar para XX9.99 ou XX4.99
$hundreds = floor($price / 100) * 100;
$remainder = $price - $hundreds;
if ($remainder >= 75) {
return $hundreds + 99.99;
} elseif ($remainder >= 25) {
return $hundreds + 49.99;
} else {
return $hundreds - 0.01;
}
}
}
/**
* Calcula a margem real baseada no preço final
*
* @param float $finalPrice
* @return float
*/
protected function calculateRealMargin(float $finalPrice): float
{
if ($finalPrice <= 0) {
return 0;
}
$cmv = (float) $this->cmv_total;
$margin = (($finalPrice - $cmv) / $finalPrice) * 100;
return round($margin, 2);
}
/**
* Retorna comparação com concorrente
*
* @return array|null
*/
public function getCompetitorComparisonAttribute(): ?array
{
if (!$this->competitor_price || $this->competitor_price <= 0) {
return null;
}
$finalPrice = (float) ($this->final_price ?? $this->sale_price);
$competitorPrice = (float) $this->competitor_price;
$difference = $finalPrice - $competitorPrice;
$percentDiff = (($finalPrice - $competitorPrice) / $competitorPrice) * 100;
return [
'competitor_price' => $competitorPrice,
'our_price' => $finalPrice,
'difference' => round($difference, 2),
'percent_difference' => round($percentDiff, 2),
'position' => $difference > 0 ? 'above' : ($difference < 0 ? 'below' : 'equal'),
];
}
/**
* Lista de estratégias disponíveis
*/
public static function getStrategies(): array
{
return [
self::STRATEGY_AGGRESSIVE => 'Agressivo (5% abaixo do concorrente)',
self::STRATEGY_NEUTRAL => 'Neutro (preço calculado)',
self::STRATEGY_PREMIUM => 'Premium (10% acima do concorrente)',
];
}
}

View File

@ -0,0 +1,419 @@
<?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\BelongsToMany;
use Carbon\Carbon;
class PromotionalCampaign extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
'code',
'description',
'discount_type',
'discount_value',
'start_date',
'end_date',
'start_time',
'end_time',
'is_active',
'apply_to_all_products',
'min_price',
'min_margin_percent',
'protect_against_loss',
'margin_warning_threshold',
'show_original_price',
'show_discount_badge',
'badge_text',
'badge_color',
'priority',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_active' => 'boolean',
'apply_to_all_products' => 'boolean',
'protect_against_loss' => 'boolean',
'show_original_price' => 'boolean',
'show_discount_badge' => 'boolean',
'discount_value' => 'decimal:2',
'min_price' => 'decimal:2',
'min_margin_percent' => 'decimal:2',
'margin_warning_threshold' => 'decimal:2',
'priority' => 'integer',
];
// Tipos de desconto
const TYPE_PERCENTAGE = 'percentage';
const TYPE_FIXED = 'fixed';
const TYPE_PRICE_OVERRIDE = 'price_override';
// Presets de campanhas
const PRESETS = [
'black_friday' => [
'name' => 'Black Friday',
'discount_type' => 'percentage',
'discount_value' => 30,
'badge_text' => 'BLACK FRIDAY',
'badge_color' => '#000000',
'show_discount_badge' => true,
],
'cyber_monday' => [
'name' => 'Cyber Monday',
'discount_type' => 'percentage',
'discount_value' => 25,
'badge_text' => 'CYBER MONDAY',
'badge_color' => '#3b82f6',
'show_discount_badge' => true,
],
'christmas' => [
'name' => 'Natal',
'discount_type' => 'percentage',
'discount_value' => 20,
'badge_text' => '🎄 NATAL',
'badge_color' => '#22c55e',
'show_discount_badge' => true,
],
'new_year' => [
'name' => 'Ano Novo',
'discount_type' => 'percentage',
'discount_value' => 15,
'badge_text' => '🎆 ANO NOVO',
'badge_color' => '#f59e0b',
'show_discount_badge' => true,
],
'summer_sale' => [
'name' => 'Liquidação de Verão',
'discount_type' => 'percentage',
'discount_value' => 40,
'badge_text' => 'LIQUIDAÇÃO',
'badge_color' => '#ef4444',
'show_discount_badge' => true,
],
'flash_sale' => [
'name' => 'Flash Sale',
'discount_type' => 'percentage',
'discount_value' => 50,
'badge_text' => '⚡ FLASH SALE',
'badge_color' => '#8b5cf6',
'show_discount_badge' => true,
],
];
/**
* Usuário dono da campanha
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Produtos na campanha
*/
public function products(): BelongsToMany
{
return $this->belongsToMany(ProductSheet::class, 'campaign_products', 'campaign_id', 'product_sheet_id')
->withPivot(['discount_type', 'discount_value', 'promotional_price', 'promo_margin', 'promo_margin_percent', 'is_profitable'])
->withTimestamps();
}
/**
* Verifica se a campanha está ativa agora
*/
public function isCurrentlyActive(): bool
{
if (!$this->is_active) {
return false;
}
$now = Carbon::now();
$today = $now->toDateString();
// Verificar data
if ($today < $this->start_date->toDateString() || $today > $this->end_date->toDateString()) {
return false;
}
// Verificar hora se definida
if ($this->start_time && $this->end_time) {
$currentTime = $now->format('H:i:s');
// Se for o primeiro dia, verificar hora de início
if ($today === $this->start_date->toDateString() && $currentTime < $this->start_time) {
return false;
}
// Se for o último dia, verificar hora de fim
if ($today === $this->end_date->toDateString() && $currentTime > $this->end_time) {
return false;
}
}
return true;
}
/**
* Calcula o preço promocional para um produto
* COM PROTEÇÃO DE RENTABILIDADE
*/
public function calculatePromotionalPrice(ProductSheet $product, ?array $override = null): float
{
$basePrice = (float) ($product->final_price ?? $product->sale_price);
if ($basePrice <= 0) {
return 0;
}
$discountType = $override['discount_type'] ?? $this->discount_type;
$discountValue = (float) ($override['discount_value'] ?? $this->discount_value);
$promoPrice = $basePrice;
switch ($discountType) {
case self::TYPE_PERCENTAGE:
$promoPrice = $basePrice * (1 - ($discountValue / 100));
break;
case self::TYPE_FIXED:
$promoPrice = $basePrice - $discountValue;
break;
case self::TYPE_PRICE_OVERRIDE:
$promoPrice = $discountValue;
break;
}
// === PROTEÇÃO DE RENTABILIDADE ===
// 1. Aplicar preço mínimo da CAMPANHA se definido
if ($this->min_price && $promoPrice < $this->min_price) {
$promoPrice = (float) $this->min_price;
}
// 2. Aplicar preço mínimo do PRODUTO se definido (proteção individual)
if ($product->min_price && $promoPrice < (float) $product->min_price) {
$promoPrice = (float) $product->min_price;
}
// 3. NUNCA vender abaixo do CMV (Custo) - Proteção contra prejuízo
$cmv = (float) ($product->cmv_total ?? 0);
if ($cmv > 0 && $promoPrice < $cmv) {
$promoPrice = $cmv; // Preço mínimo = custo (margem zero, sem prejuízo)
}
// 4. Aplicar margem mínima de segurança se configurada na campanha
if ($this->min_margin_percent && $cmv > 0) {
$minPriceWithMargin = $cmv * (1 + ($this->min_margin_percent / 100));
if ($promoPrice < $minPriceWithMargin) {
$promoPrice = $minPriceWithMargin;
}
}
// Não pode ser negativo
return max(0, round($promoPrice, 2));
}
/**
* Verifica se o preço promocional gera lucro
*/
public function isProfitable(ProductSheet $product): bool
{
$promoPrice = $this->calculatePromotionalPrice($product);
$cmv = (float) ($product->cmv_total ?? 0);
return $promoPrice > $cmv;
}
/**
* Calcula a margem real durante a promoção
*/
public function getPromotionalMargin(ProductSheet $product): array
{
$promoPrice = $this->calculatePromotionalPrice($product);
$cmv = (float) ($product->cmv_total ?? 0);
$originalPrice = (float) ($product->final_price ?? $product->sale_price);
$promoMargin = $promoPrice - $cmv;
$promoMarginPercent = $promoPrice > 0 ? ($promoMargin / $promoPrice) * 100 : 0;
$originalMargin = $originalPrice - $cmv;
$originalMarginPercent = $originalPrice > 0 ? ($originalMargin / $originalPrice) * 100 : 0;
return [
'promo_price' => $promoPrice,
'cmv' => $cmv,
'promo_margin' => round($promoMargin, 2),
'promo_margin_percent' => round($promoMarginPercent, 1),
'original_margin' => round($originalMargin, 2),
'original_margin_percent' => round($originalMarginPercent, 1),
'margin_reduction' => round($originalMarginPercent - $promoMarginPercent, 1),
'is_profitable' => $promoMargin > 0,
'is_protected' => $promoPrice >= $cmv,
];
}
/**
* Calcula o percentual de desconto
*/
public function getDiscountPercentage(float $originalPrice, float $promoPrice): float
{
if ($originalPrice <= 0) {
return 0;
}
return round((($originalPrice - $promoPrice) / $originalPrice) * 100, 1);
}
/**
* Retorna informações do badge para exibição
*/
public function getBadgeInfo(float $originalPrice, float $promoPrice): ?array
{
if (!$this->show_discount_badge) {
return null;
}
$discountPercent = $this->getDiscountPercentage($originalPrice, $promoPrice);
return [
'text' => $this->badge_text ?? "-{$discountPercent}%",
'color' => $this->badge_color ?? '#ef4444',
'discount_percent' => $discountPercent,
];
}
/**
* Aplica a campanha a todos os produtos do usuário
*/
public function applyToAllProducts(): int
{
if (!$this->apply_to_all_products) {
return 0;
}
$products = ProductSheet::where('user_id', $this->user_id)
->where('is_active', true)
->get();
$count = 0;
foreach ($products as $product) {
$promoPrice = $this->calculatePromotionalPrice($product);
$this->products()->syncWithoutDetaching([
$product->id => [
'promotional_price' => $promoPrice,
]
]);
$count++;
}
return $count;
}
/**
* Atualiza preços promocionais de todos os produtos da campanha
*/
public function recalculateAllPrices(): void
{
foreach ($this->products as $product) {
$override = null;
if ($product->pivot->discount_type) {
$override = [
'discount_type' => $product->pivot->discount_type,
'discount_value' => $product->pivot->discount_value,
];
}
$promoPrice = $this->calculatePromotionalPrice($product, $override);
$this->products()->updateExistingPivot($product->id, [
'promotional_price' => $promoPrice,
]);
}
}
/**
* Scope para campanhas do usuário
*/
public function scopeOfUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope para campanhas ativas
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope para campanhas atuais (dentro do período)
*/
public function scopeCurrent($query)
{
$now = Carbon::now();
return $query->where('start_date', '<=', $now)
->where('end_date', '>=', $now);
}
/**
* Scope para campanhas futuras
*/
public function scopeUpcoming($query)
{
return $query->where('start_date', '>', Carbon::now());
}
/**
* Lista de presets disponíveis
*/
public static function getPresets(): array
{
return self::PRESETS;
}
/**
* Dias restantes da campanha
*/
public function getDaysRemainingAttribute(): int
{
if (!$this->isCurrentlyActive()) {
return 0;
}
return max(0, Carbon::now()->diffInDays($this->end_date, false));
}
/**
* Status da campanha
*/
public function getStatusAttribute(): string
{
if (!$this->is_active) {
return 'inactive';
}
$now = Carbon::now();
if ($now < $this->start_date) {
return 'scheduled';
}
if ($now > $this->end_date) {
return 'ended';
}
return 'active';
}
}

View File

@ -0,0 +1,281 @@
<?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;
use Illuminate\Database\Eloquent\SoftDeletes;
class ServiceSheet extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'business_setting_id',
'name',
'code',
'category',
'description',
'duration_minutes',
'total_csv',
'fixed_cost_portion',
'calculated_price',
'contribution_margin',
'margin_percentage',
'competitor_price',
'min_price',
'max_price',
'premium_multiplier',
'pricing_strategy',
'target_margin',
'psychological_pricing',
'is_active',
];
protected $casts = [
'duration_minutes' => 'decimal:2',
'total_csv' => 'decimal:2',
'fixed_cost_portion' => 'decimal:2',
'calculated_price' => 'decimal:2',
'contribution_margin' => 'decimal:2',
'margin_percentage' => 'decimal:2',
'competitor_price' => 'decimal:2',
'min_price' => 'decimal:2',
'max_price' => 'decimal:2',
'premium_multiplier' => 'decimal:4',
'target_margin' => 'decimal:2',
'psychological_pricing' => 'boolean',
'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 do serviço (insumos)
*/
public function items(): HasMany
{
return $this->hasMany(ServiceSheetItem::class);
}
/**
* Calcula o CSV (Custo do Serviço Vendido) somando todos os itens
*
* @return float
*/
public function calculateTotalCsv(): float
{
return $this->items->sum(function ($item) {
return $item->unit_cost * $item->quantity_used;
});
}
/**
* Calcula o gasto fixo proporcional ao tempo do serviço
*
* @return float
*/
public function calculateFixedCostPortion(): float
{
$setting = $this->businessSetting;
if (!$setting) {
return 0;
}
return round($setting->fixed_cost_per_minute * $this->duration_minutes, 2);
}
/**
* Calcula o preço de venda usando a fórmula de serviços:
* Preço = (Gasto Fixo/Tempo + CSV) × Markup
*
* @return float
*/
public function calculatePrice(): float
{
$setting = $this->businessSetting;
if (!$setting) {
return 0;
}
$basePrice = $setting->calculateServicePrice(
(float) $this->duration_minutes,
(float) $this->total_csv
);
// Aplicar multiplicador premium
$price = $basePrice * (float) ($this->premium_multiplier ?? 1);
// Aplicar estratégia de preço baseada no competidor
if ($this->competitor_price && $this->competitor_price > 0) {
switch ($this->pricing_strategy) {
case 'aggressive':
$strategicPrice = $this->competitor_price * 0.95;
$price = min($price, $strategicPrice);
break;
case 'premium':
$strategicPrice = $this->competitor_price * 1.10;
$price = max($price, $strategicPrice);
break;
}
}
// Aplicar limites min/max
if ($this->min_price && $price < $this->min_price) {
$price = (float) $this->min_price;
}
if ($this->max_price && $price > $this->max_price) {
$price = (float) $this->max_price;
}
// Aplicar redondeo psicológico
if ($this->psychological_pricing) {
$price = $this->applyPsychologicalPricing($price);
}
return round($price, 2);
}
/**
* Aplica redondeo psicológico (€26.04 €25.99)
*/
protected function applyPsychologicalPricing(float $price): float
{
$intPart = floor($price);
$decimal = $price - $intPart;
if ($decimal > 0 && $decimal <= 0.50) {
return $intPart - 0.01;
} elseif ($decimal > 0.50) {
return $intPart + 0.99;
}
return $price - 0.01;
}
/**
* Calcula a margem de contribuição
* Margem = Preço - CSV - Gasto Fixo Proporcional - Deduções
*/
public function calculateContributionMargin(): float
{
$price = (float) $this->calculated_price;
$csv = (float) $this->total_csv;
$fixedCost = (float) $this->fixed_cost_portion;
$setting = $this->businessSetting;
if (!$setting) {
return 0;
}
// Para B2C, remover IVA do preço antes de calcular margem
if ($setting->price_includes_tax) {
$vatRate = (float) ($setting->vat_rate ?? 0);
$price = $price / (1 + $vatRate / 100);
}
// Deduções variáveis
$deductions = $price * ($setting->total_variable_costs / 100);
return round($price - $csv - $fixedCost - $deductions, 2);
}
/**
* Recalcula todos os valores e salva
*/
public function recalculate(): self
{
$this->total_csv = $this->calculateTotalCsv();
$this->fixed_cost_portion = $this->calculateFixedCostPortion();
$this->calculated_price = $this->calculatePrice();
$this->contribution_margin = $this->calculateContributionMargin();
// Calcular margem percentual
if ($this->calculated_price > 0) {
$this->margin_percentage = round(
($this->contribution_margin / $this->calculated_price) * 100,
2
);
} else {
$this->margin_percentage = 0;
}
$this->save();
return $this;
}
/**
* Retorna o breakdown de custos para exibição
*/
public function getCostBreakdownAttribute(): array
{
$setting = $this->businessSetting;
$price = (float) $this->calculated_price;
$csv = (float) $this->total_csv;
$fixedCost = (float) $this->fixed_cost_portion;
// Calcular valores
$vatAmount = 0;
$priceWithoutVat = $price;
if ($setting && $setting->price_includes_tax) {
$vatRate = (float) ($setting->vat_rate ?? 0);
$priceWithoutVat = $price / (1 + $vatRate / 100);
$vatAmount = $price - $priceWithoutVat;
}
$deductions = $setting ? $priceWithoutVat * ($setting->total_variable_costs / 100) : 0;
return [
'duration_minutes' => (float) $this->duration_minutes,
'duration_hours' => round($this->duration_minutes / 60, 2),
'csv' => $csv,
'fixed_cost_portion' => $fixedCost,
'base_cost' => $csv + $fixedCost,
'markup' => $setting ? (float) $setting->markup_factor : 0,
'price_without_vat' => round($priceWithoutVat, 2),
'vat_amount' => round($vatAmount, 2),
'vat_rate' => $setting && $setting->price_includes_tax ? (float) $setting->vat_rate : 0,
'final_price' => $price,
'deductions' => round($deductions, 2),
'contribution_margin' => (float) $this->contribution_margin,
'margin_percentage' => (float) $this->margin_percentage,
];
}
/**
* Scope para buscar serviços de um usuário
*/
public function scopeOfUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope para buscar serviços ativos
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceSheetItem extends Model
{
use HasFactory;
protected $fillable = [
'service_sheet_id',
'name',
'type',
'unit_cost',
'quantity_used',
'unit',
'notes',
];
protected $casts = [
'unit_cost' => 'decimal:2',
'quantity_used' => 'decimal:4',
];
/**
* Tipos de item disponíveis
*/
public const TYPES = [
'supply' => 'Insumo',
'consumable' => 'Descartável',
'material' => 'Material',
'equipment_usage' => 'Uso de Equipamento',
'other' => 'Outro',
];
/**
* Relacionamento com ficha de serviço
*/
public function serviceSheet(): BelongsTo
{
return $this->belongsTo(ServiceSheet::class);
}
/**
* Calcula o custo total do item
* Custo Total = Custo Unitário × Quantidade Usada
*/
public function getTotalCostAttribute(): float
{
return round((float) $this->unit_cost * (float) $this->quantity_used, 2);
}
/**
* Retorna o nome do tipo traduzido
*/
public function getTypeNameAttribute(): string
{
return self::TYPES[$this->type] ?? $this->type;
}
}

View File

@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('product_sheets', function (Blueprint $table) {
// Preço do concorrente para benchmark
$table->decimal('competitor_price', 15, 2)->nullable()->after('sale_price');
// Limites de preço
$table->decimal('min_price', 15, 2)->nullable()->after('competitor_price');
$table->decimal('max_price', 15, 2)->nullable()->after('min_price');
// Fator premium por produto (1.0 = neutro, >1 = premium, <1 = desconto)
$table->decimal('premium_multiplier', 5, 2)->default(1.00)->after('max_price');
// Estratégia de preço
$table->enum('price_strategy', ['aggressive', 'neutral', 'premium'])->default('neutral')->after('premium_multiplier');
// Arredondamento psicológico (ex: 25.99 em vez de 26.04)
$table->boolean('psychological_rounding')->default(false)->after('price_strategy');
// Margem alvo específica para este produto (override do global)
$table->decimal('target_margin_percent', 5, 2)->nullable()->after('psychological_rounding');
// Preço final calculado (após ajustes estratégicos)
$table->decimal('final_price', 15, 2)->nullable()->after('target_margin_percent');
// Margem real calculada
$table->decimal('real_margin_percent', 5, 2)->nullable()->after('final_price');
});
}
public function down(): void
{
Schema::table('product_sheets', function (Blueprint $table) {
$table->dropColumn([
'competitor_price',
'min_price',
'max_price',
'premium_multiplier',
'price_strategy',
'psychological_rounding',
'target_margin_percent',
'final_price',
'real_margin_percent',
]);
});
}
};

View File

@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Tabela de campanhas promocionais
Schema::create('promotional_campaigns', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('code', 50)->nullable(); // Código da campanha (ex: BLACKFRIDAY2025)
$table->text('description')->nullable();
$table->enum('discount_type', ['percentage', 'fixed', 'price_override'])->default('percentage');
$table->decimal('discount_value', 10, 2)->default(0); // Valor ou percentual
$table->date('start_date');
$table->date('end_date');
$table->time('start_time')->nullable(); // Hora de início (ex: 00:00:00 para Black Friday)
$table->time('end_time')->nullable(); // Hora de fim
$table->boolean('is_active')->default(true);
$table->boolean('apply_to_all_products')->default(false);
$table->decimal('min_price', 15, 2)->nullable(); // Preço mínimo durante campanha
$table->boolean('show_original_price')->default(true); // Mostrar preço riscado
$table->boolean('show_discount_badge')->default(true); // Mostrar badge de desconto
$table->string('badge_text', 50)->nullable(); // Texto do badge (ex: "-30%", "BLACK FRIDAY")
$table->string('badge_color', 20)->default('#ef4444'); // Cor do badge
$table->integer('priority')->default(0); // Prioridade (maior = prevalece)
$table->timestamps();
$table->index(['user_id', 'is_active']);
$table->index(['start_date', 'end_date']);
});
// Tabela pivot para produtos na campanha
Schema::create('campaign_products', function (Blueprint $table) {
$table->id();
$table->foreignId('campaign_id')->constrained('promotional_campaigns')->cascadeOnDelete();
$table->foreignId('product_sheet_id')->constrained('product_sheets')->cascadeOnDelete();
$table->enum('discount_type', ['percentage', 'fixed', 'price_override'])->nullable(); // Override do desconto
$table->decimal('discount_value', 10, 2)->nullable(); // Override do valor
$table->decimal('promotional_price', 15, 2)->nullable(); // Preço calculado
$table->timestamps();
$table->unique(['campaign_id', 'product_sheet_id']);
});
// Adicionar campo de preço promocional na product_sheets
Schema::table('product_sheets', function (Blueprint $table) {
$table->decimal('promotional_price', 15, 2)->nullable()->after('final_price');
$table->foreignId('active_campaign_id')->nullable()->after('promotional_price')
->constrained('promotional_campaigns')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('product_sheets', function (Blueprint $table) {
$table->dropForeign(['active_campaign_id']);
$table->dropColumn(['promotional_price', 'active_campaign_id']);
});
Schema::dropIfExists('campaign_products');
Schema::dropIfExists('promotional_campaigns');
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Adiciona campos de proteção de rentabilidade às campanhas
*/
public function up(): void
{
Schema::table('promotional_campaigns', function (Blueprint $table) {
// Margem mínima obrigatória durante promoção (%)
$table->decimal('min_margin_percent', 5, 2)->nullable()->after('min_price')
->comment('Margem mínima obrigatória sobre o CMV');
// Proteção automática contra prejuízo
$table->boolean('protect_against_loss')->default(true)->after('min_margin_percent')
->comment('Nunca vender abaixo do CMV');
// Alertar quando margem ficar muito baixa
$table->decimal('margin_warning_threshold', 5, 2)->nullable()->after('protect_against_loss')
->comment('Alertar quando margem cair abaixo deste %');
});
// Adicionar campo na tabela pivot para armazenar margem real
Schema::table('campaign_products', function (Blueprint $table) {
$table->decimal('promo_margin', 15, 2)->nullable()->after('promotional_price')
->comment('Margem real durante a promoção');
$table->decimal('promo_margin_percent', 5, 2)->nullable()->after('promo_margin')
->comment('% de margem durante promoção');
$table->boolean('is_profitable')->default(true)->after('promo_margin_percent')
->comment('Se o produto gera lucro na promoção');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('promotional_campaigns', function (Blueprint $table) {
$table->dropColumn(['min_margin_percent', 'protect_against_loss', 'margin_warning_threshold']);
});
Schema::table('campaign_products', function (Blueprint $table) {
$table->dropColumn(['promo_margin', 'promo_margin_percent', 'is_profitable']);
});
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Adds support for B2B vs B2C pricing:
* - B2B: price_includes_tax = false (tax is a cost deduction)
* - B2C: price_includes_tax = true (price shown includes VAT/IVA)
*/
public function up(): void
{
Schema::table('business_settings', function (Blueprint $table) {
// Whether the final price includes tax (B2C) or not (B2B)
$table->boolean('price_includes_tax')->default(true)->after('tax_rate');
// Tax rate for inclusion (IVA/VAT) - separate from deduction tax
// This is the tax that gets ADDED to the price, not deducted from margin
$table->decimal('vat_rate', 5, 2)->default(21.00)->after('price_includes_tax');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('business_settings', function (Blueprint $table) {
$table->dropColumn(['price_includes_tax', 'vat_rate']);
});
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Adds support for service pricing based on productive hours:
* - employees_count: Number of employees providing services
* - hours_per_day: Working hours per day
* - working_days_per_month: Working days per month
* - productivity_rate: Percentage of productive time (default 80%)
*
* Formula:
* Productive Hours = employees × hours/day × days/month × productivity%
* Fixed Cost/Hour = Fixed Expenses / Productive Hours
* Service Price = (Fixed Cost/Hour + Service Cost) × Markup
*/
public function up(): void
{
Schema::table('business_settings', function (Blueprint $table) {
// Service pricing configuration
$table->enum('business_type', ['products', 'services', 'both'])->default('products')->after('currency');
// Workforce configuration for service pricing
$table->integer('employees_count')->default(1)->after('business_type');
$table->decimal('hours_per_day', 4, 2)->default(8.00)->after('employees_count');
$table->integer('working_days_per_month')->default(22)->after('hours_per_day');
$table->decimal('productivity_rate', 5, 2)->default(80.00)->after('working_days_per_month');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('business_settings', function (Blueprint $table) {
$table->dropColumn([
'business_type',
'employees_count',
'hours_per_day',
'working_days_per_month',
'productivity_rate',
]);
});
}
};

View File

@ -0,0 +1,90 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Creates tables for service technical sheets.
* Similar to product_sheets but for services with time-based pricing.
*/
public function up(): void
{
// Service sheets (fichas técnicas de servicios)
Schema::create('service_sheets', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('business_setting_id')->constrained()->onDelete('cascade');
// Basic info
$table->string('name');
$table->string('code')->nullable(); // Service code/SKU
$table->string('category')->nullable();
$table->text('description')->nullable();
// Time configuration
$table->decimal('duration_minutes', 6, 2)->default(30.00); // Service duration in minutes
// Calculated values (cached)
$table->decimal('total_csv', 12, 2)->default(0); // Cost of Service Vendido
$table->decimal('fixed_cost_portion', 12, 2)->default(0); // Gasto fijo por tiempo
$table->decimal('calculated_price', 12, 2)->default(0);
$table->decimal('contribution_margin', 12, 2)->default(0);
$table->decimal('margin_percentage', 5, 2)->default(0);
// Strategic pricing (same as products)
$table->decimal('competitor_price', 12, 2)->nullable();
$table->decimal('min_price', 12, 2)->nullable();
$table->decimal('max_price', 12, 2)->nullable();
$table->decimal('premium_multiplier', 5, 4)->default(1.0000);
$table->enum('pricing_strategy', ['aggressive', 'neutral', 'premium'])->default('neutral');
$table->decimal('target_margin', 5, 2)->nullable();
$table->boolean('psychological_pricing', )->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index(['user_id', 'business_setting_id']);
$table->index(['user_id', 'is_active']);
});
// Service sheet items (insumos/componentes del servicio)
Schema::create('service_sheet_items', function (Blueprint $table) {
$table->id();
$table->foreignId('service_sheet_id')->constrained()->onDelete('cascade');
$table->string('name'); // Nombre del insumo
$table->enum('type', [
'supply', // Insumos (champú, crema, etc.)
'consumable', // Descartables (gillette, guantes, etc.)
'material', // Materiales
'equipment_usage', // Uso de equipo (amortización)
'other'
])->default('supply');
$table->decimal('unit_cost', 12, 2); // Costo por unidad
$table->decimal('quantity_used', 10, 4)->default(1); // Cantidad usada por servicio
$table->string('unit')->nullable(); // ml, g, unidad, etc.
$table->text('notes')->nullable();
$table->timestamps();
$table->index('service_sheet_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('service_sheet_items');
Schema::dropIfExists('service_sheets');
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Changes hours_per_day to hours_per_week as the key metric.
* Default: 40 hours/week (8h × 5 days)
* Derives: hours_per_day = hours_per_week / working_days_per_week
*/
public function up(): void
{
Schema::table('business_settings', function (Blueprint $table) {
// Add hours_per_week column
$table->decimal('hours_per_week', 5, 2)->default(40.00)->after('employees_count');
// Add working_days_per_week (for calculation)
$table->integer('working_days_per_week')->default(5)->after('hours_per_week');
});
// Convert existing hours_per_day to hours_per_week
// hours_per_week = hours_per_day * 5 (assuming 5-day week)
DB::table('business_settings')->update([
'hours_per_week' => DB::raw('hours_per_day * 5'),
'working_days_per_week' => 5,
]);
// Remove old hours_per_day column
Schema::table('business_settings', function (Blueprint $table) {
$table->dropColumn('hours_per_day');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('business_settings', function (Blueprint $table) {
$table->decimal('hours_per_day', 4, 2)->default(8.00)->after('employees_count');
});
// Convert back: hours_per_day = hours_per_week / 5
DB::table('business_settings')->update([
'hours_per_day' => DB::raw('hours_per_week / 5'),
]);
Schema::table('business_settings', function (Blueprint $table) {
$table->dropColumn(['hours_per_week', 'working_days_per_week']);
});
}
};

View File

@ -0,0 +1,288 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\BusinessSetting;
use App\Models\ProductSheet;
use App\Models\ProductSheetItem;
use App\Models\ServiceSheet;
use App\Models\ServiceSheetItem;
class BusinessExampleSeeder extends Seeder
{
/**
* Seed business examples with 3 different types:
* 1. Products only (E-commerce)
* 2. Services only (Consultoria)
* 3. Both (Gráfica + Design)
*/
public function run(): void
{
$userId = 3; // Marco Leite
// =====================================================================
// NEGÓCIO 1: E-COMMERCE DE ELETRÔNICOS (Apenas Produtos)
// =====================================================================
$ecommerce = BusinessSetting::create([
'user_id' => $userId,
'name' => 'TechStore - E-commerce',
'currency' => 'EUR',
'business_type' => 'products',
'employees_count' => 3,
'hours_per_week' => 40,
'working_days_per_week' => 5,
'working_days_per_month' => 22,
'productivity_rate' => 85,
'monthly_revenue' => 45000,
'fixed_expenses' => 8500,
'tax_rate' => 21,
'price_includes_tax' => true,
'vat_rate' => 21,
'sales_commission' => 3,
'card_fee' => 1.5,
'other_variable_costs' => 2,
'investment_rate' => 5,
'profit_margin' => 15,
'is_active' => true,
]);
$ecommerce->recalculateMarkup();
// Produtos do E-commerce
$this->createProduct($ecommerce, 'Smartphone Samsung Galaxy S24', 'PHONE-001', 'Eletrônicos', [
['name' => 'Custo do produto', 'cost' => 650, 'quantity' => 1],
['name' => 'Embalagem premium', 'cost' => 5, 'quantity' => 1],
['name' => 'Frete de entrada', 'cost' => 8, 'quantity' => 1],
]);
$this->createProduct($ecommerce, 'Notebook Dell Inspiron 15', 'NOTE-001', 'Eletrônicos', [
['name' => 'Custo do notebook', 'cost' => 580, 'quantity' => 1],
['name' => 'Embalagem', 'cost' => 12, 'quantity' => 1],
['name' => 'Frete importação', 'cost' => 25, 'quantity' => 1],
]);
$this->createProduct($ecommerce, 'Fone Bluetooth JBL', 'AUDIO-001', 'Áudio', [
['name' => 'Custo do fone', 'cost' => 45, 'quantity' => 1],
['name' => 'Embalagem', 'cost' => 2, 'quantity' => 1],
]);
$this->createProduct($ecommerce, 'Smartwatch Apple Watch SE', 'WATCH-001', 'Wearables', [
['name' => 'Custo do relógio', 'cost' => 220, 'quantity' => 1],
['name' => 'Embalagem premium', 'cost' => 8, 'quantity' => 1],
]);
$this->createProduct($ecommerce, 'Carregador USB-C 65W', 'ACC-001', 'Acessórios', [
['name' => 'Custo do carregador', 'cost' => 18, 'quantity' => 1],
['name' => 'Embalagem', 'cost' => 1, 'quantity' => 1],
]);
// =====================================================================
// NEGÓCIO 2: CONSULTORIA DE TI (Apenas Serviços)
// =====================================================================
$consultoria = BusinessSetting::create([
'user_id' => $userId,
'name' => 'DevPro Consultoria',
'currency' => 'EUR',
'business_type' => 'services',
'employees_count' => 2,
'hours_per_week' => 40,
'working_days_per_week' => 5,
'working_days_per_month' => 22,
'productivity_rate' => 75,
'monthly_revenue' => 18000,
'fixed_expenses' => 3500,
'tax_rate' => 21,
'price_includes_tax' => false,
'vat_rate' => 21,
'sales_commission' => 0,
'card_fee' => 0,
'other_variable_costs' => 1,
'investment_rate' => 10,
'profit_margin' => 25,
'is_active' => true,
]);
$consultoria->recalculateMarkup();
// Serviços da Consultoria (duração em minutos)
$this->createService($consultoria, 'Desenvolvimento Web - React', 'DEV-001', 'Desenvolvimento', [
['name' => 'Infraestrutura cloud', 'cost' => 5, 'quantity' => 1],
['name' => 'Licenças software', 'cost' => 3, 'quantity' => 1],
], 480); // 8 horas
$this->createService($consultoria, 'Consultoria Arquitetura Cloud', 'CONS-001', 'Consultoria', [
['name' => 'Ferramentas de análise', 'cost' => 10, 'quantity' => 1],
], 240); // 4 horas
$this->createService($consultoria, 'Manutenção Mensal WordPress', 'MAINT-001', 'Manutenção', [
['name' => 'Hosting dedicado', 'cost' => 15, 'quantity' => 1],
['name' => 'Backup automático', 'cost' => 5, 'quantity' => 1],
], 180); // 3 horas
$this->createService($consultoria, 'Setup E-commerce Shopify', 'SETUP-001', 'Implementação', [
['name' => 'Tema premium', 'cost' => 150, 'quantity' => 1],
['name' => 'Apps necessários', 'cost' => 50, 'quantity' => 1],
], 1200); // 20 horas
$this->createService($consultoria, 'Auditoria de Segurança', 'SEC-001', 'Segurança', [
['name' => 'Ferramentas de scan', 'cost' => 25, 'quantity' => 1],
['name' => 'Relatório detalhado', 'cost' => 10, 'quantity' => 1],
], 600); // 10 horas
// =====================================================================
// NEGÓCIO 3: GRÁFICA E DESIGN (Produtos + Serviços)
// =====================================================================
$grafica = BusinessSetting::create([
'user_id' => $userId,
'name' => 'Print & Design Studio',
'currency' => 'EUR',
'business_type' => 'both',
'employees_count' => 4,
'hours_per_week' => 44,
'working_days_per_week' => 6,
'working_days_per_month' => 26,
'productivity_rate' => 80,
'monthly_revenue' => 28000,
'fixed_expenses' => 6200,
'tax_rate' => 21,
'price_includes_tax' => true,
'vat_rate' => 21,
'sales_commission' => 2,
'card_fee' => 1.2,
'other_variable_costs' => 3,
'investment_rate' => 8,
'profit_margin' => 20,
'is_active' => true,
]);
$grafica->recalculateMarkup();
// Produtos da Gráfica
$this->createProduct($grafica, 'Cartões de Visita (500 unid)', 'CARD-001', 'Impressos', [
['name' => 'Papel couché 300g', 'cost' => 8, 'quantity' => 1],
['name' => 'Tinta e impressão', 'cost' => 12, 'quantity' => 1],
['name' => 'Corte e acabamento', 'cost' => 5, 'quantity' => 1],
]);
$this->createProduct($grafica, 'Banner Roll-up 85x200cm', 'BANNER-001', 'Grandes Formatos', [
['name' => 'Lona impressa', 'cost' => 25, 'quantity' => 1],
['name' => 'Estrutura roll-up', 'cost' => 35, 'quantity' => 1],
['name' => 'Bolsa de transporte', 'cost' => 8, 'quantity' => 1],
]);
$this->createProduct($grafica, 'Flyers A5 (1000 unid)', 'FLYER-001', 'Impressos', [
['name' => 'Papel couché 150g', 'cost' => 15, 'quantity' => 1],
['name' => 'Impressão offset', 'cost' => 20, 'quantity' => 1],
]);
$this->createProduct($grafica, 'Catálogo 20 páginas', 'CAT-001', 'Impressos', [
['name' => 'Papel interior', 'cost' => 30, 'quantity' => 1],
['name' => 'Capa dura', 'cost' => 15, 'quantity' => 1],
['name' => 'Encadernação', 'cost' => 10, 'quantity' => 1],
['name' => 'Impressão', 'cost' => 45, 'quantity' => 1],
]);
// Serviços da Gráfica
$this->createService($grafica, 'Design de Logotipo', 'LOGO-001', 'Design', [
['name' => 'Banco de imagens', 'cost' => 5, 'quantity' => 1],
['name' => 'Fontes premium', 'cost' => 10, 'quantity' => 1],
], 360); // 6 horas
$this->createService($grafica, 'Design de Embalagem', 'PACK-001', 'Design', [
['name' => 'Mockups 3D', 'cost' => 15, 'quantity' => 1],
], 720); // 12 horas
$this->createService($grafica, 'Identidade Visual Completa', 'BRAND-001', 'Branding', [
['name' => 'Manual de marca', 'cost' => 20, 'quantity' => 1],
['name' => 'Arquivos fonte', 'cost' => 10, 'quantity' => 1],
['name' => 'Aplicações básicas', 'cost' => 15, 'quantity' => 1],
], 1500); // 25 horas
$this->createService($grafica, 'Design de Post Redes Sociais', 'SOCIAL-001', 'Marketing', [
['name' => 'Templates Canva Pro', 'cost' => 2, 'quantity' => 1],
], 60); // 1 hora
$this->command->info('Business examples seeded successfully!');
$this->command->info('- TechStore (Products): ' . $ecommerce->productSheets()->count() . ' products');
$this->command->info('- DevPro (Services): ' . $consultoria->serviceSheets()->count() . ' services');
$this->command->info('- Print & Design (Both): ' . $grafica->productSheets()->count() . ' products, ' . $grafica->serviceSheets()->count() . ' services');
}
/**
* Create a product with items
*/
private function createProduct(BusinessSetting $setting, string $name, string $sku, string $category, array $items): ProductSheet
{
$product = ProductSheet::create([
'user_id' => $setting->user_id,
'business_setting_id' => $setting->id,
'name' => $name,
'sku' => $sku,
'category' => $category,
'currency' => $setting->currency,
'is_active' => true,
]);
foreach ($items as $item) {
ProductSheetItem::create([
'product_sheet_id' => $product->id,
'name' => $item['name'],
'amount' => $item['cost'],
'quantity' => $item['quantity'],
'unit_cost' => $item['cost'],
'type' => 'product_cost',
]);
}
// Recalculate totals
$product->refresh();
$product->cmv_total = $product->items->sum(fn($i) => ($i->amount ?? $i->unit_cost) * $i->quantity);
$product->sale_price = $product->calculateSalePrice($setting);
$product->final_price = $product->calculateFinalPrice();
$product->save();
return $product;
}
/**
* Create a service with items
*/
private function createService(BusinessSetting $setting, string $name, string $code, string $category, array $items, int $durationMinutes): ServiceSheet
{
// Calculate CSV
$totalCsv = collect($items)->sum(fn($i) => $i['cost'] * $i['quantity']);
$service = ServiceSheet::create([
'user_id' => $setting->user_id,
'business_setting_id' => $setting->id,
'name' => $name,
'code' => $code,
'category' => $category,
'duration_minutes' => $durationMinutes,
'total_csv' => $totalCsv,
'premium_multiplier' => 1.0,
'psychological_pricing' => false,
'is_active' => true,
]);
foreach ($items as $item) {
ServiceSheetItem::create([
'service_sheet_id' => $service->id,
'name' => $item['name'],
'type' => 'supply',
'unit_cost' => $item['cost'],
'quantity_used' => $item['quantity'],
]);
}
// Recalculate pricing
$service->refresh();
$service->fixed_cost_portion = $service->calculateFixedCostPortion();
$service->calculated_price = $service->calculatePrice();
$service->contribution_margin = $service->calculateContributionMargin();
$service->margin_percentage = $service->calculated_price > 0
? round(($service->contribution_margin / $service->calculated_price) * 100, 2)
: 0;
$service->save();
return $service;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace Database\Seeders;
use App\Models\BusinessSetting;
use App\Models\ProductSheet;
use App\Models\ProductSheetItem;
use App\Models\User;
use Illuminate\Database\Seeder;
class BusinessSeeder extends Seeder
{
public function run(): void
{
$user = User::first();
if (!$user) {
$this->command->error('No user found!');
return;
}
// Criar configuração de markup
$setting = BusinessSetting::create([
'user_id' => $user->id,
'name' => 'Loja Principal',
'currency' => 'EUR',
'monthly_revenue' => 50000,
'fixed_expenses' => 6000,
'tax_rate' => 10,
'sales_commission' => 5,
'card_fee' => 3,
'other_variable_costs' => 2,
'investment_rate' => 5,
'profit_margin' => 15,
'is_active' => true,
]);
$setting->recalculateMarkup();
$this->command->info("Setting criado: {$setting->name} - Markup: {$setting->markup_factor}");
// Criar produtos de exemplo com fichas técnicas
$products = [
['name' => 'Camiseta Básica', 'sku' => 'CAM-001', 'cmv' => 12.50, 'category' => 'Vestuário'],
['name' => 'Calça Jeans', 'sku' => 'CAL-001', 'cmv' => 35.00, 'category' => 'Vestuário'],
['name' => 'Tênis Esportivo', 'sku' => 'TEN-001', 'cmv' => 65.00, 'category' => 'Calçados'],
['name' => 'Mochila Escolar', 'sku' => 'MOC-001', 'cmv' => 28.00, 'category' => 'Acessórios'],
['name' => 'Boné Trucker', 'sku' => 'BON-001', 'cmv' => 8.50, 'category' => 'Acessórios'],
['name' => 'Relógio Digital', 'sku' => 'REL-001', 'cmv' => 45.00, 'category' => 'Acessórios'],
['name' => 'Óculos de Sol', 'sku' => 'OCU-001', 'cmv' => 22.00, 'category' => 'Acessórios'],
['name' => 'Jaqueta Corta-Vento', 'sku' => 'JAQ-001', 'cmv' => 55.00, 'category' => 'Vestuário'],
['name' => 'Shorts Fitness', 'sku' => 'SHO-001', 'cmv' => 18.00, 'category' => 'Vestuário'],
['name' => 'Meias Pack 3', 'sku' => 'MEI-001', 'cmv' => 6.00, 'category' => 'Vestuário'],
];
foreach ($products as $productData) {
// Criar a ficha técnica
$sheet = ProductSheet::create([
'user_id' => $user->id,
'business_setting_id' => $setting->id,
'name' => $productData['name'],
'sku' => $productData['sku'],
'description' => 'Produto de exemplo',
'category' => $productData['category'],
'currency' => 'EUR',
'cmv_total' => $productData['cmv'],
'is_active' => true,
]);
// Criar item de custo principal
ProductSheetItem::create([
'product_sheet_id' => $sheet->id,
'name' => 'Custo de Aquisição',
'type' => ProductSheetItem::TYPE_PRODUCT_COST,
'amount' => $productData['cmv'] * 0.8,
'quantity' => 1,
'unit' => 'un',
'unit_cost' => $productData['cmv'] * 0.8,
'sort_order' => 1,
]);
// Criar item de embalagem
ProductSheetItem::create([
'product_sheet_id' => $sheet->id,
'name' => 'Embalagem',
'type' => ProductSheetItem::TYPE_PACKAGING,
'amount' => $productData['cmv'] * 0.15,
'quantity' => 1,
'unit' => 'un',
'unit_cost' => $productData['cmv'] * 0.15,
'sort_order' => 2,
]);
// Criar item de etiqueta
ProductSheetItem::create([
'product_sheet_id' => $sheet->id,
'name' => 'Etiqueta',
'type' => ProductSheetItem::TYPE_LABEL,
'amount' => $productData['cmv'] * 0.05,
'quantity' => 1,
'unit' => 'un',
'unit_cost' => $productData['cmv'] * 0.05,
'sort_order' => 3,
]);
// Calcular preço de venda
$sheet->calculateSalePrice($setting);
$this->command->info("Produto: {$sheet->name} - CMV: EUR {$sheet->cmv_total} - Venda: EUR {$sheet->sale_price}");
}
$this->command->info('Business seeder concluído!');
}
}

View File

@ -15,6 +15,8 @@
use App\Http\Controllers\Api\RecurringTemplateController;
use App\Http\Controllers\Api\BusinessSettingController;
use App\Http\Controllers\Api\ProductSheetController;
use App\Http\Controllers\Api\ServiceSheetController;
use App\Http\Controllers\Api\PromotionalCampaignController;
// Public routes with rate limiting
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
@ -211,5 +213,24 @@
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']);
// Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido)
Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']);
Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']);
Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']);
Route::apiResource('service-sheets', ServiceSheetController::class);
Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']);
Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']);
Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']);
Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']);
// Campanhas Promocionais (Black Friday, etc.)
Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']);
Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']);
Route::apiResource('campaigns', PromotionalCampaignController::class);
Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']);
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
});

219
deploy.ps1 Normal file
View File

@ -0,0 +1,219 @@
# =============================================================================
# WEBMoney - Script de Deploy Unificado
# =============================================================================
# Este script faz deploy do frontend, backend ou ambos para o servidor
#
# Uso:
# .\deploy.ps1 # Deploy de ambos (frontend + backend)
# .\deploy.ps1 -Target frontend # Apenas frontend
# .\deploy.ps1 -Target backend # Apenas backend
# .\deploy.ps1 -Target both # Ambos (padrao)
#
# Requer: Node.js, npm, PuTTY (pscp, plink) instalados no PATH
# =============================================================================
param(
[ValidateSet('frontend', 'backend', 'both')]
[string]$Target = 'both'
)
$ErrorActionPreference = "Stop"
# Configuracoes
$SERVER_USER = "root"
$SERVER_HOST = "213.165.93.60"
$SERVER_PASS = "Master9354"
$PROJECT_ROOT = $PSScriptRoot
# Paths
$FRONTEND_LOCAL = "$PROJECT_ROOT\frontend"
$FRONTEND_DIST = "$FRONTEND_LOCAL\dist"
$FRONTEND_REMOTE = "/var/www/webmoney/frontend/dist"
$BACKEND_LOCAL = "$PROJECT_ROOT\backend"
$BACKEND_REMOTE = "/var/www/webmoney/backend"
# Cores
function Write-Color {
param([string]$Text, [string]$Color = "White")
Write-Host $Text -ForegroundColor $Color
}
function Write-Header {
param([string]$Text)
Write-Host ""
Write-Color "========================================" "Cyan"
Write-Color " $Text" "Cyan"
Write-Color "========================================" "Cyan"
Write-Host ""
}
function Write-Step {
param([string]$Step, [string]$Text)
Write-Color "[$Step] $Text" "Yellow"
}
function Write-OK {
param([string]$Text)
Write-Color "[OK] $Text" "Green"
}
function Write-Fail {
param([string]$Text)
Write-Color "[ERRO] $Text" "Red"
}
# =============================================================================
# DEPLOY FRONTEND
# =============================================================================
function Deploy-Frontend {
Write-Header "DEPLOY FRONTEND"
# 1. Build
Write-Step "1/4" "Fazendo build do frontend..."
Set-Location $FRONTEND_LOCAL
if (Test-Path $FRONTEND_DIST) {
Remove-Item -Recurse -Force $FRONTEND_DIST
}
npm run build
if (-not (Test-Path $FRONTEND_DIST)) {
Write-Fail "Build falhou - pasta dist nao encontrada"
exit 1
}
Write-OK "Build concluido"
Write-Host ""
# 2. Limpar diretorio remoto
Write-Step "2/4" "Limpando diretorio remoto..."
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "rm -rf $FRONTEND_REMOTE/*"
Write-OK "Diretorio limpo"
Write-Host ""
# 3. Enviar arquivos
Write-Step "3/4" "Enviando arquivos..."
pscp -r -batch -pw $SERVER_PASS "$FRONTEND_DIST\*" "${SERVER_USER}@${SERVER_HOST}:${FRONTEND_REMOTE}/"
Write-OK "Arquivos enviados"
Write-Host ""
# 4. Verificar
Write-Step "4/4" "Verificando deploy..."
$result = plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "ls $FRONTEND_REMOTE/index.html 2>/dev/null"
if (-not $result) {
Write-Fail "index.html nao encontrado"
exit 1
}
Write-OK "Frontend deployado com sucesso"
Write-Host ""
Set-Location $PROJECT_ROOT
}
# =============================================================================
# DEPLOY BACKEND
# =============================================================================
function Deploy-Backend {
Write-Header "DEPLOY BACKEND"
# 1. Enviar pastas
Write-Step "1/5" "Enviando pastas do backend..."
$folders = @("app", "bootstrap", "config", "database", "public", "resources", "routes")
foreach ($folder in $folders) {
$folderPath = "$BACKEND_LOCAL\$folder"
if (Test-Path $folderPath) {
Write-Host " -> $folder"
pscp -r -batch -pw $SERVER_PASS "$folderPath" "${SERVER_USER}@${SERVER_HOST}:${BACKEND_REMOTE}/"
}
}
Write-OK "Pastas enviadas"
Write-Host ""
# 2. Enviar arquivos
Write-Step "2/5" "Enviando arquivos..."
$files = @("artisan", "composer.json", "composer.lock")
foreach ($file in $files) {
$filePath = "$BACKEND_LOCAL\$file"
if (Test-Path $filePath) {
Write-Host " -> $file"
pscp -batch -pw $SERVER_PASS "$filePath" "${SERVER_USER}@${SERVER_HOST}:${BACKEND_REMOTE}/"
}
}
Write-OK "Arquivos enviados"
Write-Host ""
# 3. Composer install
Write-Step "3/5" "Instalando dependencias..."
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "cd $BACKEND_REMOTE && composer install --no-dev --optimize-autoloader 2>&1 | tail -3"
Write-OK "Dependencias instaladas"
Write-Host ""
# 4. Migracoes e cache
Write-Step "4/5" "Executando migracoes e otimizacoes..."
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "cd $BACKEND_REMOTE && php artisan migrate --force && php artisan optimize:clear && php artisan config:cache && php artisan route:cache && php artisan view:cache"
Write-OK "Migracoes e caches OK"
Write-Host ""
# 5. Permissoes e PHP-FPM
Write-Step "5/5" "Ajustando permissoes e reiniciando PHP-FPM..."
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "chown -R www-data:www-data $BACKEND_REMOTE/storage $BACKEND_REMOTE/bootstrap/cache && chmod -R 775 $BACKEND_REMOTE/storage $BACKEND_REMOTE/bootstrap/cache && systemctl restart php8.4-fpm"
Write-OK "Backend deployado com sucesso"
Write-Host ""
}
# =============================================================================
# REINICIAR NGINX (sempre ao final)
# =============================================================================
function Restart-Nginx {
Write-Step "FINAL" "Reiniciando Nginx para limpar cache..."
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "systemctl restart nginx"
Write-OK "Nginx reiniciado"
Write-Host ""
}
# =============================================================================
# MAIN
# =============================================================================
Write-Host ""
Write-Color "========================================" "Magenta"
Write-Color " WEBMoney - Deploy Unificado " "Magenta"
Write-Color " Target: $Target " "Magenta"
Write-Color "========================================" "Magenta"
Write-Host ""
$startTime = Get-Date
# Executar deploy conforme target
switch ($Target) {
'frontend' {
Deploy-Frontend
}
'backend' {
Deploy-Backend
}
'both' {
Deploy-Backend
Deploy-Frontend
}
}
# Sempre reiniciar Nginx ao final
Restart-Nginx
$endTime = Get-Date
$duration = $endTime - $startTime
Write-Color "========================================" "Green"
Write-Color " DEPLOY CONCLUIDO COM SUCESSO! " "Green"
Write-Color "========================================" "Green"
Write-Host ""
Write-Host " Target: $Target"
Write-Host " Duracao: $($duration.TotalSeconds.ToString('F1')) segundos"
Write-Host " URL: https://webmoney.cnxifly.com"
Write-Host ""
Write-Host " Dica: Use Ctrl+Shift+R no navegador para limpar cache local"
Write-Host ""

418
docs/MODULO_NEGOCIOS.txt Normal file
View File

@ -0,0 +1,418 @@
================================================================================
WEBMONEY - MÓDULO DE NEGOCIOS
Guía Completa de Funcionamiento
================================================================================
ÍNDICE
------
1. Visión General
2. Pestaña: Configuraciones de Negocio
3. Pestaña: Fichas Técnicas de Productos
4. Pestaña: Campañas Promocionales
5. Pestaña: Calculadora de Precios
6. Flujo de Trabajo Recomendado
7. Protección de Rentabilidad
8. Casos de Uso Prácticos
================================================================================
1. VISIÓN GENERAL
================================================================================
El Módulo de Negocios es una herramienta integral para la gestión de precios,
costos y promociones de productos. Está diseñado para ayudar a emprendedores
y pequeñas empresas a:
• Calcular precios de venta basados en costos reales
• Mantener márgenes de ganancia saludables
• Crear campañas promocionales sin perder dinero
• Comparar precios con la competencia
• Tomar decisiones informadas sobre precificación
• Trabajar con precios B2B (sin IVA) o B2C (con IVA incluido)
El módulo se divide en 4 pestañas principales que trabajan de forma integrada.
================================================================================
2. PESTAÑA: CONFIGURACIONES DE NEGOCIO
================================================================================
¿PARA QUÉ SIRVE?
----------------
Define los parámetros globales de markup y deducciones que se aplicarán a todos
los productos. Es la base del cálculo de precios.
CAMPOS PRINCIPALES:
-------------------
• Nombre: Identificador de la configuración (ej: "Tienda Online", "Mayorista")
• Moneda: EUR, USD, BRL, GBP
TIPO DE PRECIO (B2B vs B2C):
----------------------------
El sistema soporta dos modos de cálculo de precios:
B2C (Business to Consumer) - Venta al consumidor final:
─────────────────────────────────────────────────────────
• El precio final INCLUYE el IVA/VAT
• El IVA NO se resta del margen, se AÑADE al precio final
• Fórmula: Precio = CMV × Markup × (1 + IVA%)
• Ideal para: Tiendas online, retail, venta directa
• Ejemplo: PVP €24.20 (incluye 21% IVA)
B2B (Business to Business) - Venta entre empresas:
─────────────────────────────────────────────────────────
• El precio final NO incluye IVA (se factura aparte)
• Los impuestos se tratan como deducción del margen
• Fórmula: Precio = CMV × Markup
• Ideal para: Mayoristas, distribuidores, proveedores
• Ejemplo: Precio neto €20.00 + IVA (factura separada)
• Tasa de IVA/VAT (%): Solo visible en modo B2C (ej: 21% España, 23% Portugal)
• Otros Impuestos (%): Tasas especiales además del IVA
DEDUCCIONES (se descuentan del precio de venta):
------------------------------------------------
• Tarifa de Plataforma (%): Comisiones de marketplaces (Amazon, Mercado Libre)
• Tarifa de Pago (%): Comisiones de pasarelas (PayPal, Stripe)
• Envío (%): Costo de envío como porcentaje
• Marketing (%): Inversión en publicidad
• Otros (%): Costos adicionales
EJEMPLO B2C (Precio incluye IVA):
---------------------------------
CMV (Costo): €10.00
Markup Base: 2.5× (150%)
IVA: 21%
Otras deducciones: 15% (plataforma + pago)
Precio sin IVA = €10.00 × 2.5 = €25.00
Precio con IVA = €25.00 × 1.21 = €30.25 (PVP)
Deducciones = €25.00 × 15% = €3.75
Margen Neto = €25.00 - €10.00 - €3.75 = €11.25 (45% sobre CMV)
El preview muestra: Markup Base (2.5) × (1 + IVA) = Multiplicador Final (3.025)
EJEMPLO B2B (Precio sin IVA):
-----------------------------
CMV (Costo): €10.00
Markup: 2.5× (150%)
Impuestos como deducción: 21%
Plataforma: 15%
Precio Neto = €10.00 × 2.5 = €25.00
Deducciones = €25.00 × 36% = €9.00
Margen Neto = €25.00 - €10.00 - €9.00 = €6.00 (24% sobre CMV)
El cliente recibe factura: €25.00 + 21% IVA = €30.25
================================================================================
3. PESTAÑA: FICHAS TÉCNICAS DE PRODUCTOS
================================================================================
¿PARA QUÉ SIRVE?
----------------
Registra cada producto con todos sus componentes de costo (CMV - Costo de
Mercancía Vendida) y calcula automáticamente el precio de venta sugerido.
INFORMACIÓN BÁSICA:
-------------------
• Nombre del Producto
• SKU/Código
• Categoría
• Configuración de Negocio asociada
• Estado (Activo/Inactivo)
COMPONENTES DE COSTO (CMV):
---------------------------
Cada producto puede tener múltiples componentes:
Tipo Ejemplo
─────────────────────────────────────────
Costo del Producto Materia prima, producto al por mayor
Embalaje Cajas, bolsas, papel
Etiqueta Tags, stickers
Envío Flete del proveedor
Manipulación Mano de obra de preparación
Otros Cualquier costo adicional
Cada componente tiene: Nombre, Tipo, Valor, Cantidad
PRECIFICACIÓN ESTRATÉGICA (Avanzado):
-------------------------------------
• Precio del Competidor: Para comparar tu precio vs mercado
• Precio Mínimo: Nunca vender por debajo de este valor
• Precio Máximo: Límite superior
• Multiplicador Premium: Ajuste fino (1.0 = neutro)
• Estrategia de Precio:
- Agresivo: -5% del competidor
- Neutro: Precio calculado
- Premium: +10% del competidor
• Margen Objetivo (%): Sobrescribe el markup global para este producto
• Redondeo Psicológico: Convierte €26.04 en €25.99
CÁLCULOS AUTOMÁTICOS:
---------------------
CMV Total = Suma de todos los componentes de costo
Precio de Venta = CMV × (1 + Markup%) ÷ (1 - Deducciones%)
Margen de Contribución = Precio - CMV - Deducciones
Margen Real (%) = (Margen de Contribución ÷ Precio) × 100
================================================================================
4. PESTAÑA: CAMPAÑAS PROMOCIONALES
================================================================================
¿PARA QUÉ SIRVE?
----------------
Crea y gestiona ofertas temporales con descuentos, manteniendo la rentabilidad
gracias al sistema de protección automática.
PRESETS RÁPIDOS:
----------------
• Black Friday (30% descuento, badge negro)
• Cyber Monday (25% descuento, badge azul)
• Navidad (20% descuento, badge verde)
• Año Nuevo (15% descuento, badge naranja)
• Liquidación de Verano (40% descuento, badge rojo)
CONFIGURACIÓN DE CAMPAÑA:
-------------------------
Paso 1 - Detalles:
• Nombre y Código
• Tipo de Descuento:
- Porcentaje: 30% off
- Valor Fijo: €5 off
- Precio Fijo: Todo a €9.99
• Período: Fecha/hora inicio y fin
• Badge visual (texto y color)
Paso 2 - Productos:
• Seleccionar productos específicos
• O aplicar a todos los productos
Paso 3 - Revisión:
• Vista previa del impacto en precios
• Análisis de rentabilidad
• Alertas de productos con pérdida
PROTECCIÓN DE RENTABILIDAD:
---------------------------
El sistema incluye protecciones automáticas para evitar vender con pérdida:
✓ Nunca vender con pérdida: El precio nunca será menor que el CMV
✓ Margen Mínimo: Garantiza un % mínimo de ganancia
✓ Precio Mínimo: Límite absoluto inferior
Ejemplo con protección:
Producto: Camiseta
CMV: €15.00
Precio Normal: €30.00
Descuento Black Friday: 60%
Precio sin protección: €12.00 (PÉRDIDA de €3.00)
Precio con protección: €15.00 (CMV, sin pérdida)
ESTADOS DE CAMPAÑA:
-------------------
• Activa: En ejecución ahora
• Programada: Iniciará en el futuro
• Finalizada: Ya terminó
• Inactiva: Desactivada manualmente
================================================================================
5. PESTAÑA: CALCULADORA DE PRECIOS
================================================================================
¿PARA QUÉ SIRVE?
----------------
Herramienta rápida para simular precios sin necesidad de crear productos.
Ideal para cotizaciones y análisis rápidos.
FUNCIONALIDADES:
----------------
• Ingresa un CMV y ve el precio calculado instantáneamente
• Prueba diferentes configuraciones de negocio
• Compara márgenes entre escenarios
• Exporta simulaciones
================================================================================
6. FLUJO DE TRABAJO RECOMENDADO
================================================================================
CONFIGURACIÓN INICIAL:
----------------------
1. Crear al menos una Configuración de Negocio
→ Define tu markup estándar y deducciones
2. Registrar tus Productos
→ Agrega todos los componentes de costo
→ Vincula con la configuración de negocio
3. Revisar los Precios Calculados
→ Verifica que los márgenes sean adecuados
→ Ajusta el markup si es necesario
PARA CREAR UNA PROMOCIÓN:
-------------------------
1. Ir a Campañas Promocionales
2. Usar un preset o crear desde cero
3. Configurar descuento y período
4. Activar "Protección de Rentabilidad"
5. Seleccionar productos
6. Revisar el impacto en la vista previa
7. Crear la campaña
MANTENIMIENTO CONTINUO:
-----------------------
• Actualizar costos cuando cambien los proveedores
• Revisar márgenes mensualmente
• Ajustar markup según la competencia
• Analizar rendimiento de campañas pasadas
================================================================================
7. PROTECCIÓN DE RENTABILIDAD - DETALLE TÉCNICO
================================================================================
El sistema aplica 4 capas de protección al calcular precios promocionales:
CAPA 1 - CMV (Costo):
→ El precio nunca será menor que el costo del producto
→ Evita pérdidas absolutas
CAPA 2 - Precio Mínimo del Producto:
→ Respeta el precio mínimo definido en la ficha técnica
→ Útil para productos con valor de marca
CAPA 3 - Precio Mínimo de Campaña:
→ Límite global para toda la campaña
→ Ej: "Ningún producto por menos de €5"
CAPA 4 - Margen Mínimo:
→ Garantiza un porcentaje de ganancia sobre el CMV
→ Ej: "Siempre al menos 10% de margen"
ORDEN DE APLICACIÓN:
--------------------
El sistema calcula el precio promocional y luego aplica la protección
más restrictiva que corresponda:
precio_final = MAX(
precio_promocional,
cmv,
precio_minimo_producto,
precio_minimo_campaña,
cmv × (1 + margen_minimo%)
)
================================================================================
8. CASOS DE USO PRÁCTICOS
================================================================================
CASO 1: TIENDA DE ROPA ONLINE
-----------------------------
Configuración:
• Markup: 150%
• Impuestos: 21%
• Plataforma (Shopify): 2%
• Pago (Stripe): 3%
• Marketing: 10%
Producto: Camiseta Básica
• Costo tela: €3.00
• Costo confección: €2.00
• Embalaje: €0.50
• CMV Total: €5.50
• Precio Calculado: €18.99
Black Friday:
• Descuento: 30%
• Precio Promo: €13.29
• Margen: €7.79 (58% sobre CMV) ✓ Rentable
─────────────────────────────────────────────────────────────────────────────
CASO 2: VENTA DE ELECTRÓNICOS EN AMAZON
----------------------------------------
Configuración:
• Markup: 80%
• Impuestos: 21%
• Plataforma (Amazon): 15%
• Pago: 0% (incluido en Amazon)
• Marketing: 5%
Producto: Cable USB-C
• Costo producto: €2.00
• Embalaje: €0.30
• CMV Total: €2.30
• Precio Calculado: €6.99
Cyber Monday:
• Descuento: 25%
• Precio sin protección: €5.24
• Con margen mínimo 10%: €5.24 (mantiene porque CMV×1.1 = €2.53)
• Margen final: €2.94 (127% sobre CMV) ✓ Muy rentable
─────────────────────────────────────────────────────────────────────────────
CASO 3: PRODUCTO DE ALTO COSTO CON BAJO MARGEN
----------------------------------------------
Configuración:
• Markup: 30%
• Deducciones totales: 25%
Producto: Laptop
• CMV: €800.00
• Precio Normal: €1,386.67
• Margen Normal: €240.00 (30%)
Liquidación 50%:
• Precio sin protección: €693.33 (PÉRDIDA de €106.67)
• Con protección CMV: €800.00 (sin pérdida)
• Con margen mínimo 5%: €840.00 (ganancia €40)
El sistema automáticamente ajusta a €840.00 para mantener el 5% de margen.
─────────────────────────────────────────────────────────────────────────────
CASO 4: MISMO PRODUCTO EN B2B Y B2C
-----------------------------------
Una empresa vende tanto a consumidores (B2C) como a otras empresas (B2B).
Producto: Auriculares Bluetooth
• CMV: €15.00
Configuración B2C (Tienda Online):
• Tipo: B2C (precio incluye IVA)
• IVA: 21%
• Markup Base: 2.0×
• Deducciones: 18%
• Precio = €15 × 2.0 × 1.21 = €36.30 (PVP con IVA)
• El IVA (€6.30) va al gobierno, no afecta tu margen
Configuración B2B (Distribuidora):
• Tipo: B2B (precio sin IVA)
• Markup: 1.5×
• Deducciones: 10%
• Precio = €15 × 1.5 = €22.50 (neto)
• Factura al cliente: €22.50 + €4.73 IVA = €27.23
Ventaja: Puedes tener dos configuraciones diferentes para el mismo producto,
una para cada canal de ventas.
================================================================================
RESUMEN
-------
El Módulo de Negocios de WebMoney te permite:
✓ Calcular precios basados en costos reales
✓ Mantener márgenes de ganancia consistentes
✓ Crear promociones atractivas sin perder dinero
✓ Comparar con la competencia
✓ Tomar decisiones de precio informadas
✓ Trabajar en modo B2C (IVA incluido) o B2B (IVA separado)
✓ Gestionar múltiples canales de venta con diferentes configuraciones
La clave está en configurar correctamente los costos y el tipo de precio
(B2B o B2C) para que el sistema calcule los precios óptimos mientras
protege tu rentabilidad.
================================================================================
FIN DEL DOCUMENTO
================================================================================

2
fix_migration.sh Normal file
View File

@ -0,0 +1,2 @@
mysql -u webmoney -p'M@ster9354' webmoney -e "INSERT INTO migrations (migration, batch) VALUES ('2025_12_08_000004_create_transactions_table', 1)"
php artisan migrate --force

View File

@ -59,10 +59,26 @@ Write-Color "Arquivos enviados" "Green"
Write-Host ""
# 4. Verificar deploy
Write-Color "[4/4] Verificando deploy..." "Yellow"
Write-Color "[4/5] Verificando deploy..." "Yellow"
$result = plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "ls $REMOTE_PATH/index.html 2>/dev/null"
if ($result) {
if (-not $result) {
Write-Color "========================================" "Red"
Write-Color " ERRO: index.html nao encontrado " "Red"
Write-Color "========================================" "Red"
exit 1
}
Write-Color "Arquivos verificados" "Green"
Write-Host ""
# 5. Reiniciar Nginx para limpar cache
Write-Color "[5/5] Reiniciando Nginx para limpar cache..." "Yellow"
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "systemctl restart nginx"
Write-Color "Nginx reiniciado" "Green"
Write-Host ""
Write-Host ""
Write-Color "========================================" "Green"
Write-Color " Deploy concluido com sucesso! " "Green"
@ -70,11 +86,5 @@ if ($result) {
Write-Host ""
Write-Host "Acesse: https://webmoney.cnxifly.com"
Write-Host ""
Write-Host "Dica: Use Ctrl+Shift+R no navegador para limpar o cache"
Write-Host "Dica: Use Ctrl+Shift+R no navegador para limpar o cache local"
Write-Host ""
} else {
Write-Color "========================================" "Red"
Write-Color " ERRO: index.html nao encontrado " "Red"
Write-Color "========================================" "Red"
exit 1
}

View File

@ -9,9 +9,17 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
const [formData, setFormData] = useState({
name: '',
currency: 'EUR',
business_type: 'products',
employees_count: '1',
hours_per_week: '40',
working_days_per_week: '5',
working_days_per_month: '22',
productivity_rate: '80',
monthly_revenue: '',
fixed_expenses: '',
tax_rate: '',
price_includes_tax: true,
vat_rate: '21',
sales_commission: '',
card_fee: '',
other_variable_costs: '',
@ -29,14 +37,22 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
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 || '',
business_type: setting.business_type || 'products',
employees_count: setting.employees_count?.toString() || '1',
hours_per_week: setting.hours_per_week?.toString() || '40',
working_days_per_week: setting.working_days_per_week?.toString() || '5',
working_days_per_month: setting.working_days_per_month?.toString() || '22',
productivity_rate: setting.productivity_rate?.toString() || '80',
monthly_revenue: setting.monthly_revenue?.toString() || '',
fixed_expenses: setting.fixed_expenses?.toString() || '',
tax_rate: setting.tax_rate?.toString() || '',
price_includes_tax: setting.price_includes_tax ?? true,
vat_rate: setting.vat_rate?.toString() || '21',
sales_commission: setting.sales_commission?.toString() || '',
card_fee: setting.card_fee?.toString() || '',
other_variable_costs: setting.other_variable_costs?.toString() || '',
investment_rate: setting.investment_rate?.toString() || '',
profit_margin: setting.profit_margin?.toString() || '',
is_active: setting.is_active ?? true,
});
}
@ -47,33 +63,61 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
const monthlyRevenue = parseFloat(formData.monthly_revenue) || 0;
const fixedExpenses = parseFloat(formData.fixed_expenses) || 0;
const taxRate = parseFloat(formData.tax_rate) || 0;
const priceIncludesTax = formData.price_includes_tax;
const vatRate = parseFloat(formData.vat_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
// Calcular horas produtivas (para serviços)
// Chave: horas por semana - as outras são derivadas
const employees = parseInt(formData.employees_count) || 1;
const hoursPerWeek = parseFloat(formData.hours_per_week) || 40;
const daysPerWeek = parseInt(formData.working_days_per_week) || 5;
const daysPerMonth = parseInt(formData.working_days_per_month) || 22;
const productivity = parseFloat(formData.productivity_rate) || 80;
// Derivar horas por dia a partir de horas por semana
const hoursPerDay = daysPerWeek > 0 ? hoursPerWeek / daysPerWeek : 8;
// Horas produtivas = funcionários × horas/dia × dias/mês × produtividade
const productiveHours = employees * hoursPerDay * daysPerMonth * (productivity / 100);
const fixedCostPerHour = productiveHours > 0 ? fixedExpenses / productiveHours : 0;
// Calcular % despesas fixas (para produtos)
const fixedExpensesRate = monthlyRevenue > 0 ? (fixedExpenses / monthlyRevenue) * 100 : 0;
// Total custos variáveis
const totalVariableCosts = taxRate + salesCommission + cardFee + otherVariableCosts;
// Total custos variáveis - se B2C, tax_rate NÃO entra nas deduções
const totalVariableCosts = (priceIncludesTax ? 0 : taxRate) + salesCommission + cardFee + otherVariableCosts;
// Total deduções (em decimal)
const totalDeductions = (fixedExpensesRate + totalVariableCosts + investmentRate + profitMargin) / 100;
// Markup
// Markup base (sem IVA)
let markup = 0;
if (totalDeductions < 1) {
markup = 1 / (1 - totalDeductions);
}
// Se B2C, o preço final inclui IVA
const finalMultiplier = priceIncludesTax ? markup * (1 + vatRate / 100) : markup;
setPreview({
fixedExpensesRate: fixedExpensesRate.toFixed(2),
totalVariableCosts: totalVariableCosts.toFixed(2),
totalDeductions: (totalDeductions * 100).toFixed(2),
markup: markup.toFixed(4),
finalMultiplier: finalMultiplier.toFixed(4),
vatRate: vatRate.toFixed(2),
priceIncludesTax,
isValid: totalDeductions < 1,
// Para serviços - derivados de horas por semana
hoursPerWeek: hoursPerWeek.toFixed(1),
hoursPerDay: hoursPerDay.toFixed(2),
productiveHours: productiveHours.toFixed(1),
fixedCostPerHour: fixedCostPerHour.toFixed(2),
});
}, [formData]);
@ -93,9 +137,17 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
try {
const dataToSend = {
...formData,
business_type: formData.business_type,
employees_count: parseInt(formData.employees_count) || 1,
hours_per_week: parseFloat(formData.hours_per_week) || 40,
working_days_per_week: parseInt(formData.working_days_per_week) || 5,
working_days_per_month: parseInt(formData.working_days_per_month) || 22,
productivity_rate: parseFloat(formData.productivity_rate) || 80,
monthly_revenue: parseFloat(formData.monthly_revenue) || 0,
fixed_expenses: parseFloat(formData.fixed_expenses) || 0,
tax_rate: parseFloat(formData.tax_rate) || 0,
price_includes_tax: formData.price_includes_tax,
vat_rate: parseFloat(formData.vat_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,
@ -122,8 +174,8 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
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-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" style={{ maxHeight: '90vh' }}>
<div className="modal-content" style={{ background: '#1e293b', border: 'none', maxHeight: '90vh' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className="bi bi-gear me-2"></i>
@ -133,7 +185,7 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="modal-body" style={{ maxHeight: 'calc(90vh - 130px)', overflowY: 'auto' }}>
{error && (
<div className="alert alert-danger">{typeof error === 'string' ? error : JSON.stringify(error)}</div>
)}
@ -167,6 +219,161 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
</select>
</div>
{/* Tipo de Negócio */}
<div className="col-12">
<hr className="border-secondary my-2" />
<h6 className="text-slate-400 small mb-3">
<i className="bi bi-shop me-2"></i>
{t('business.settings.businessType')}
</h6>
</div>
<div className="col-12">
<div className="row g-2">
<div className="col-4">
<div
className={`p-2 rounded border text-center cursor-pointer ${formData.business_type === 'products' ? 'border-primary bg-primary bg-opacity-10' : 'border-secondary'}`}
onClick={() => setFormData(prev => ({ ...prev, business_type: 'products' }))}
style={{ cursor: 'pointer' }}
>
<i className={`bi bi-box-seam fs-4 ${formData.business_type === 'products' ? 'text-primary' : 'text-slate-400'}`}></i>
<div className={`small fw-bold mt-1 ${formData.business_type === 'products' ? 'text-white' : 'text-slate-400'}`}>
{t('business.settings.typeProducts')}
</div>
</div>
</div>
<div className="col-4">
<div
className={`p-2 rounded border text-center cursor-pointer ${formData.business_type === 'services' ? 'border-success bg-success bg-opacity-10' : 'border-secondary'}`}
onClick={() => setFormData(prev => ({ ...prev, business_type: 'services' }))}
style={{ cursor: 'pointer' }}
>
<i className={`bi bi-person-workspace fs-4 ${formData.business_type === 'services' ? 'text-success' : 'text-slate-400'}`}></i>
<div className={`small fw-bold mt-1 ${formData.business_type === 'services' ? 'text-white' : 'text-slate-400'}`}>
{t('business.settings.typeServices')}
</div>
</div>
</div>
<div className="col-4">
<div
className={`p-2 rounded border text-center cursor-pointer ${formData.business_type === 'both' ? 'border-warning bg-warning bg-opacity-10' : 'border-secondary'}`}
onClick={() => setFormData(prev => ({ ...prev, business_type: 'both' }))}
style={{ cursor: 'pointer' }}
>
<i className={`bi bi-shop-window fs-4 ${formData.business_type === 'both' ? 'text-warning' : 'text-slate-400'}`}></i>
<div className={`small fw-bold mt-1 ${formData.business_type === 'both' ? 'text-white' : 'text-slate-400'}`}>
{t('business.settings.typeBoth')}
</div>
</div>
</div>
</div>
</div>
{/* Configuração de Horas Produtivas - Solo para servicios */}
{(formData.business_type === 'services' || formData.business_type === 'both') && (
<>
<div className="col-12">
<div className="card bg-success bg-opacity-10 border-success">
<div className="card-body py-3">
<h6 className="text-success mb-3">
<i className="bi bi-clock-history me-2"></i>
{t('business.settings.productiveHours')}
</h6>
<div className="row g-2">
<div className="col-6 col-md-3">
<label className="form-label text-slate-400 small">{t('business.settings.employeesCount')}</label>
<input
type="number"
min="1"
className="form-control form-control-sm bg-dark text-white border-secondary"
name="employees_count"
value={formData.employees_count}
onChange={handleChange}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label text-slate-400 small">{t('business.settings.hoursPerWeek')}</label>
<input
type="number"
step="0.5"
min="1"
max="168"
className="form-control form-control-sm bg-dark text-white border-secondary"
name="hours_per_week"
value={formData.hours_per_week}
onChange={handleChange}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label text-slate-400 small">{t('business.settings.daysPerWeek')}</label>
<input
type="number"
min="1"
max="7"
className="form-control form-control-sm bg-dark text-white border-secondary"
name="working_days_per_week"
value={formData.working_days_per_week}
onChange={handleChange}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label text-slate-400 small">{t('business.settings.productivity')}</label>
<div className="input-group input-group-sm">
<input
type="number"
min="10"
max="100"
className="form-control bg-dark text-white border-secondary"
name="productivity_rate"
value={formData.productivity_rate}
onChange={handleChange}
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
</div>
</div>
<div className="row g-2 mt-1">
<div className="col-6 col-md-3">
<label className="form-label text-slate-400 small">{t('business.settings.workingDays')}</label>
<input
type="number"
min="1"
max="31"
className="form-control form-control-sm bg-dark text-white border-secondary"
name="working_days_per_month"
value={formData.working_days_per_month}
onChange={handleChange}
/>
</div>
<div className="col-6 col-md-9">
<label className="form-label text-slate-400 small">{t('business.settings.derivedHoursPerDay')}</label>
<input
type="text"
readOnly
className="form-control form-control-sm bg-secondary bg-opacity-25 text-slate-300 border-secondary"
value={preview ? `${preview.hoursPerDay} ${t('business.common.hoursPerDayUnit')}` : '-'}
/>
</div>
</div>
{preview && (
<div className="mt-3 pt-3 border-top border-success border-opacity-25">
<div className="row text-center small">
<div className="col-6">
<span className="text-slate-400">{t('business.settings.totalProductiveHours')}:</span>
<span className="text-white ms-2 fw-bold">{preview.productiveHours} h/{t('common.month')}</span>
</div>
<div className="col-6">
<span className="text-slate-400">{t('business.settings.fixedCostPerHour')}:</span>
<span className="text-success ms-2 fw-bold">{formData.currency} {preview.fixedCostPerHour}{t('business.common.perHourUnit')}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</>
)}
{/* Receita e Despesas Fixas */}
<div className="col-12">
<hr className="border-secondary my-2" />
@ -175,6 +382,9 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
{t('business.settings.revenueAndExpenses')}
</h6>
</div>
{/* Receita mensal - Solo para productos */}
{(formData.business_type === 'products' || formData.business_type === 'both') && (
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.settings.monthlyRevenue')}</label>
<input
@ -185,11 +395,12 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
value={formData.monthly_revenue}
onChange={handleChange}
placeholder="40000.00"
required
required={formData.business_type === 'products'}
/>
<small className="text-slate-500">{t('business.settings.monthlyRevenueHelp')}</small>
</div>
<div className="col-md-6">
)}
<div className={formData.business_type === 'services' ? 'col-12' : 'col-md-6'}>
<label className="form-label text-slate-400">{t('business.settings.fixedExpenses')}</label>
<input
type="number"
@ -212,8 +423,78 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
{t('business.settings.variableCosts')} (%)
</h6>
</div>
{/* B2B vs B2C - Tipo de Precio */}
<div className="col-12">
<div className="card bg-info bg-opacity-10 border-info mb-3">
<div className="card-body py-3">
<div className="d-flex align-items-start gap-3">
<i className="bi bi-info-circle text-info fs-4"></i>
<div className="flex-grow-1">
<h6 className="text-info mb-2">{t('business.settings.priceType')}</h6>
<div className="d-flex gap-4">
<div className="form-check">
<input
type="radio"
className="form-check-input"
id="priceB2C"
name="price_includes_tax"
checked={formData.price_includes_tax === true}
onChange={() => setFormData(prev => ({ ...prev, price_includes_tax: true }))}
/>
<label className="form-check-label" htmlFor="priceB2C">
<strong className="text-white">B2C</strong>
<small className="d-block text-slate-400">{t('business.settings.b2cDescription')}</small>
</label>
</div>
<div className="form-check">
<input
type="radio"
className="form-check-input"
id="priceB2B"
name="price_includes_tax"
checked={formData.price_includes_tax === false}
onChange={() => setFormData(prev => ({ ...prev, price_includes_tax: false }))}
/>
<label className="form-check-label" htmlFor="priceB2B">
<strong className="text-white">B2B</strong>
<small className="d-block text-slate-400">{t('business.settings.b2bDescription')}</small>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* IVA/VAT Rate - Solo visible en B2C */}
{formData.price_includes_tax && (
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.settings.taxRate')}</label>
<label className="form-label text-slate-400">
{t('business.settings.vatRate')}
<i className="bi bi-info-circle ms-1" title={t('business.settings.vatRateHelp')}></i>
</label>
<div className="input-group">
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="vat_rate"
value={formData.vat_rate}
onChange={handleChange}
placeholder="21"
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
<small className="text-slate-500">{t('business.settings.vatRateExample')}</small>
</div>
)}
<div className="col-md-3">
<label className="form-label text-slate-400">
{formData.price_includes_tax ? t('business.settings.otherTaxes') : t('business.settings.taxRate')}
</label>
<div className="input-group">
<input
type="number"
@ -222,11 +503,14 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
name="tax_rate"
value={formData.tax_rate}
onChange={handleChange}
placeholder="9"
placeholder={formData.price_includes_tax ? "0" : "9"}
required
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
{formData.price_includes_tax && (
<small className="text-slate-500">{t('business.settings.otherTaxesHelp')}</small>
)}
</div>
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.settings.salesCommission')}</label>
@ -342,12 +626,45 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
<span className="text-slate-500">{t('business.settings.totalDeductions')}:</span>
<span className="text-warning ms-2">{preview.totalDeductions}%</span>
</div>
{preview.priceIncludesTax && (
<div className="col-6 col-md-3">
<span className="text-slate-500">{t('business.settings.vatRate')}:</span>
<span className="text-info ms-2">{preview.vatRate}%</span>
</div>
)}
</div>
{preview.isValid ? (
<div className="text-center">
{preview.priceIncludesTax ? (
<>
<div className="d-flex justify-content-center align-items-center gap-4">
<div>
<small className="text-slate-500 d-block">{t('business.settings.baseMarkup')}</small>
<span className="text-white fs-5">{preview.markup}</span>
</div>
<span className="text-slate-500 fs-4">×</span>
<div>
<small className="text-slate-500 d-block">{t('business.common.plusVat')}</small>
<span className="text-info fs-5">{(1 + parseFloat(preview.vatRate) / 100).toFixed(2)}</span>
</div>
<span className="text-slate-500 fs-4">=</span>
<div>
<small className="text-slate-500 d-block">{t('business.settings.finalMultiplier')}</small>
<span className="text-primary fs-4 fw-bold">{preview.finalMultiplier}</span>
</div>
</div>
<small className="text-info mt-2 d-block">
<i className="bi bi-info-circle me-1"></i>
{t('business.settings.vatIncluded')}
</small>
</>
) : (
<>
<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">

View File

@ -23,7 +23,7 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
};
const handleDelete = async (setting) => {
if (!window.confirm(t('business.confirmDelete'))) return;
if (!window.confirm(t('business.settings.confirmDelete'))) return;
setDeleting(setting.id);
try {
@ -124,7 +124,7 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
{/* 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>
<h2 className="text-primary mb-0">{parseFloat(setting.markup_factor || 0).toFixed(2)}</h2>
</div>
{/* Breakdown */}
@ -144,13 +144,13 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
<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>
<span className="text-warning">{parseFloat(setting.fixed_expenses_rate || 0).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>
<span className="text-warning">{parseFloat(setting.total_variable_costs || 0).toFixed(2)}%</span>
</div>
</div>
</div>

View File

@ -0,0 +1,933 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { campaignService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
const CampaignModal = ({ isOpen, onClose, onSaved, campaign, presets, sheets }) => {
const { t } = useTranslation();
const { currency } = useFormatters();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [activeStep, setActiveStep] = useState(1);
const [preview, setPreview] = useState(null);
const [formData, setFormData] = useState({
name: '',
code: '',
description: '',
discount_type: 'percentage',
discount_value: 10,
start_date: '',
end_date: '',
start_time: '',
end_time: '',
is_active: true,
apply_to_all_products: false,
min_price: '',
min_margin_percent: '',
protect_against_loss: true,
show_original_price: true,
show_discount_badge: true,
badge_text: '',
badge_color: '#ef4444',
priority: 0,
product_ids: [],
});
// Preset colors for badges
const badgeColors = [
{ name: 'Red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Yellow', value: '#eab308' },
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Purple', value: '#8b5cf6' },
{ name: 'Pink', value: '#ec4899' },
{ name: 'Black', value: '#000000' },
];
// Initialize form when campaign changes
useEffect(() => {
if (isOpen) {
if (campaign?.id) {
// Editing existing campaign
setFormData({
name: campaign.name || '',
code: campaign.code || '',
description: campaign.description || '',
discount_type: campaign.discount_type || 'percentage',
discount_value: parseFloat(campaign.discount_value) || 10,
start_date: campaign.start_date?.split('T')[0] || '',
end_date: campaign.end_date?.split('T')[0] || '',
start_time: campaign.start_time || '',
end_time: campaign.end_time || '',
is_active: campaign.is_active !== false,
apply_to_all_products: campaign.apply_to_all_products || false,
min_price: campaign.min_price || '',
min_margin_percent: campaign.min_margin_percent || '',
protect_against_loss: campaign.protect_against_loss !== false,
show_original_price: campaign.show_original_price !== false,
show_discount_badge: campaign.show_discount_badge !== false,
badge_text: campaign.badge_text || '',
badge_color: campaign.badge_color || '#ef4444',
priority: campaign.priority || 0,
product_ids: campaign.products?.map(p => p.id) || [],
});
setActiveStep(1);
} else if (campaign?.preset && presets[campaign.preset]) {
// Creating from preset
const preset = presets[campaign.preset];
const today = new Date();
const endDate = new Date();
endDate.setDate(endDate.getDate() + 7);
setFormData({
name: preset.name || '',
code: preset.code || '',
description: preset.description || '',
discount_type: preset.discount_type || 'percentage',
discount_value: parseFloat(preset.discount_value) || 10,
start_date: today.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
start_time: preset.start_time || '',
end_time: preset.end_time || '',
is_active: true,
apply_to_all_products: false,
min_price: preset.min_price || '',
min_margin_percent: '',
protect_against_loss: true,
show_original_price: preset.show_original_price !== false,
show_discount_badge: preset.show_discount_badge !== false,
badge_text: preset.badge_text || '',
badge_color: preset.badge_color || '#ef4444',
priority: preset.priority || 0,
product_ids: [],
});
setActiveStep(1);
} else {
// New campaign
const today = new Date();
const endDate = new Date();
endDate.setDate(endDate.getDate() + 7);
setFormData({
name: '',
code: '',
description: '',
discount_type: 'percentage',
discount_value: 10,
start_date: today.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
start_time: '',
end_time: '',
is_active: true,
apply_to_all_products: false,
min_price: '',
min_margin_percent: '',
protect_against_loss: true,
show_original_price: true,
show_discount_badge: true,
badge_text: '',
badge_color: '#ef4444',
priority: 0,
product_ids: [],
});
setActiveStep(1);
}
setError(null);
setPreview(null);
}
}, [isOpen, campaign, presets]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleProductToggle = (productId) => {
setFormData(prev => ({
...prev,
product_ids: prev.product_ids.includes(productId)
? prev.product_ids.filter(id => id !== productId)
: [...prev.product_ids, productId],
}));
};
const handleSelectAllProducts = () => {
const allIds = sheets.map(s => s.id);
setFormData(prev => ({
...prev,
product_ids: prev.product_ids.length === allIds.length ? [] : allIds,
}));
};
// Preview calculation
const loadPreview = async () => {
if (formData.product_ids.length === 0 && !formData.apply_to_all_products) return;
try {
const productIds = formData.apply_to_all_products
? sheets.map(s => s.id)
: formData.product_ids;
const result = await campaignService.preview({
discount_type: formData.discount_type,
discount_value: parseFloat(formData.discount_value) || 0,
min_price: formData.min_price ? parseFloat(formData.min_price) : null,
min_margin_percent: formData.min_margin_percent ? parseFloat(formData.min_margin_percent) : null,
protect_against_loss: formData.protect_against_loss,
product_ids: productIds,
});
setPreview(result);
} catch (err) {
console.error('Preview error:', err);
}
};
useEffect(() => {
if (activeStep === 3) {
loadPreview();
}
}, [activeStep, formData.discount_type, formData.discount_value, formData.min_price, formData.min_margin_percent, formData.protect_against_loss, formData.product_ids]);
// Form validation
const isStepValid = useMemo(() => {
switch (activeStep) {
case 1:
return formData.name && formData.discount_value > 0 && formData.start_date && formData.end_date;
case 2:
return formData.apply_to_all_products || formData.product_ids.length > 0;
case 3:
return true;
default:
return false;
}
}, [activeStep, formData]);
const handleSubmit = async () => {
setLoading(true);
setError(null);
try {
const data = {
...formData,
discount_value: parseFloat(formData.discount_value) || 0,
min_price: formData.min_price ? parseFloat(formData.min_price) : null,
priority: parseInt(formData.priority) || 0,
};
let result;
if (campaign?.id) {
result = await campaignService.update(campaign.id, data);
} else {
result = await campaignService.create(data);
}
onSaved(result);
} catch (err) {
setError(err.response?.data?.message || err.response?.data?.errors || t('common.error'));
} finally {
setLoading(false);
}
};
// Moved BEFORE conditional return to respect React hooks rules
const discountPreview = useMemo(() => {
const basePrice = 100;
let promoPrice = basePrice;
switch (formData.discount_type) {
case 'percentage':
promoPrice = basePrice * (1 - (parseFloat(formData.discount_value) || 0) / 100);
break;
case 'fixed':
promoPrice = basePrice - (parseFloat(formData.discount_value) || 0);
break;
case 'price_override':
promoPrice = parseFloat(formData.discount_value) || 0;
break;
}
return { original: basePrice, promo: Math.max(0, promoPrice) };
}, [formData.discount_type, formData.discount_value]);
if (!isOpen) return null;
return (
<div className="modal fade show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div className="modal-content bg-dark text-white">
{/* Header */}
<div className="modal-header border-secondary">
<h5 className="modal-title">
<i className="bi bi-megaphone me-2"></i>
{campaign?.id ? t('campaigns.edit') : t('campaigns.create')}
</h5>
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
</div>
{/* Progress Steps */}
<div className="px-4 pt-3">
<div className="d-flex justify-content-between">
{[1, 2, 3].map(step => (
<div
key={step}
className={`flex-fill text-center ${step < 3 ? 'border-bottom pb-2' : ''}`}
style={{
borderColor: step <= activeStep ? '#3b82f6' : 'rgba(255,255,255,0.2)',
borderWidth: step <= activeStep ? '2px' : '1px',
}}
>
<span
className={`badge rounded-circle ${step === activeStep ? 'bg-primary' : step < activeStep ? 'bg-success' : 'bg-secondary'}`}
style={{ width: '24px', height: '24px', lineHeight: '16px' }}
>
{step < activeStep ? <i className="bi bi-check"></i> : step}
</span>
<small className={`d-block mt-1 ${step === activeStep ? 'text-primary' : 'text-slate-400'}`}>
{step === 1 && t('campaigns.steps.details')}
{step === 2 && t('campaigns.steps.products')}
{step === 3 && t('campaigns.steps.review')}
</small>
</div>
))}
</div>
</div>
{/* Body */}
<div className="modal-body">
{error && (
<div className="alert alert-danger d-flex align-items-center mb-3">
<i className="bi bi-exclamation-triangle me-2"></i>
{typeof error === 'object' ? JSON.stringify(error) : error}
</div>
)}
{/* Step 1: Campaign Details */}
{activeStep === 1 && (
<div className="step-content">
{/* Nome e Código */}
<div className="row g-3 mb-3">
<div className="col-md-8">
<label className="form-label">{t('campaigns.form.name')} *</label>
<input
type="text"
className="form-control bg-dark text-white"
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t('campaigns.form.namePlaceholder')}
/>
</div>
<div className="col-md-4">
<label className="form-label">{t('campaigns.form.code')}</label>
<input
type="text"
className="form-control bg-dark text-white"
name="code"
value={formData.code}
onChange={handleChange}
placeholder="BLACK2024"
/>
</div>
</div>
{/* Descrição */}
<div className="mb-3">
<label className="form-label">{t('campaigns.form.description')}</label>
<textarea
className="form-control bg-dark text-white"
name="description"
rows="2"
value={formData.description}
onChange={handleChange}
></textarea>
</div>
{/* Tipo e Valor do Desconto */}
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.discountType')} *</label>
<select
className="form-select bg-dark text-white"
name="discount_type"
value={formData.discount_type}
onChange={handleChange}
>
<option value="percentage">{t('campaigns.discountTypes.percentage')}</option>
<option value="fixed">{t('campaigns.discountTypes.fixed')}</option>
<option value="price_override">{t('campaigns.discountTypes.priceOverride')}</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.discountValue')} *</label>
<div className="input-group">
{formData.discount_type !== 'percentage' && (
<span className="input-group-text bg-secondary text-white">$</span>
)}
<input
type="number"
className="form-control bg-dark text-white"
name="discount_value"
value={formData.discount_value}
onChange={handleChange}
min="0"
step={formData.discount_type === 'percentage' ? '1' : '0.01'}
/>
{formData.discount_type === 'percentage' && (
<span className="input-group-text bg-secondary text-white">%</span>
)}
</div>
</div>
</div>
{/* Preview do Desconto */}
<div className="alert alert-info d-flex align-items-center justify-content-center mb-3">
<span className="text-muted me-2">{t('campaigns.form.example')}:</span>
<span className="text-decoration-line-through me-2">{currency(discountPreview.original)}</span>
<i className="bi bi-arrow-right mx-2"></i>
<span className="fw-bold text-success">{currency(discountPreview.promo)}</span>
</div>
{/* PROTEÇÃO DE RENTABILIDADE */}
<div className="card bg-success bg-opacity-10 border-success mb-3">
<div className="card-body">
<h6 className="text-success mb-3">
<i className="bi bi-shield-check me-2"></i>
{t('campaigns.form.profitProtection')}
</h6>
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input"
id="protect_against_loss"
name="protect_against_loss"
checked={formData.protect_against_loss}
onChange={handleChange}
/>
<label className="form-check-label" htmlFor="protect_against_loss">
<strong>{t('campaigns.form.protectAgainstLoss')}</strong>
<small className="d-block text-slate-400">{t('campaigns.form.protectAgainstLossHelp')}</small>
</label>
</div>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.minPrice')}</label>
<input
type="number"
className="form-control bg-dark text-white"
name="min_price"
value={formData.min_price}
onChange={handleChange}
min="0"
step="0.01"
placeholder={t('campaigns.form.minPricePlaceholder')}
/>
<small className="text-slate-400">{t('campaigns.form.minPriceHelp')}</small>
</div>
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.minMarginPercent')}</label>
<div className="input-group">
<input
type="number"
className="form-control bg-dark text-white"
name="min_margin_percent"
value={formData.min_margin_percent}
onChange={handleChange}
min="0"
max="100"
step="1"
placeholder="5"
/>
<span className="input-group-text bg-secondary text-white">%</span>
</div>
<small className="text-slate-400">{t('campaigns.form.minMarginHelp')}</small>
</div>
</div>
</div>
</div>
{/* Datas */}
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.startDate')} *</label>
<input
type="date"
className="form-control bg-dark text-white"
name="start_date"
value={formData.start_date}
onChange={handleChange}
/>
</div>
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.endDate')} *</label>
<input
type="date"
className="form-control bg-dark text-white"
name="end_date"
value={formData.end_date}
onChange={handleChange}
/>
</div>
</div>
{/* Horas */}
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.startTime')}</label>
<input
type="time"
className="form-control bg-dark text-white"
name="start_time"
value={formData.start_time}
onChange={handleChange}
/>
</div>
<div className="col-md-6">
<label className="form-label">{t('campaigns.form.endTime')}</label>
<input
type="time"
className="form-control bg-dark text-white"
name="end_time"
value={formData.end_time}
onChange={handleChange}
/>
</div>
</div>
{/* Badge Settings */}
<div className="card bg-secondary bg-opacity-25 mb-3">
<div className="card-body">
<h6 className="text-white mb-3">
<i className="bi bi-tag me-2"></i>
{t('campaigns.form.badgeSettings')}
</h6>
<div className="form-check mb-2">
<input
type="checkbox"
className="form-check-input"
id="show_discount_badge"
name="show_discount_badge"
checked={formData.show_discount_badge}
onChange={handleChange}
/>
<label className="form-check-label" htmlFor="show_discount_badge">
{t('campaigns.form.showBadge')}
</label>
</div>
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input"
id="show_original_price"
name="show_original_price"
checked={formData.show_original_price}
onChange={handleChange}
/>
<label className="form-check-label" htmlFor="show_original_price">
{t('campaigns.form.showOriginalPrice')}
</label>
</div>
{formData.show_discount_badge && (
<>
<div className="mb-3">
<label className="form-label">{t('campaigns.form.badgeText')}</label>
<input
type="text"
className="form-control bg-dark text-white"
name="badge_text"
value={formData.badge_text}
onChange={handleChange}
placeholder="-30% OFF"
/>
</div>
<div className="mb-2">
<label className="form-label">{t('campaigns.form.badgeColor')}</label>
<div className="d-flex flex-wrap gap-2">
{badgeColors.map(color => (
<button
key={color.value}
type="button"
className={`btn btn-sm ${formData.badge_color === color.value ? 'border-2 border-white' : 'border-0'}`}
style={{
backgroundColor: color.value,
width: '32px',
height: '32px',
}}
onClick={() => setFormData(prev => ({ ...prev, badge_color: color.value }))}
></button>
))}
</div>
</div>
{/* Badge Preview */}
<div className="mt-3">
<span className="text-slate-400 small me-2">{t('campaigns.form.preview')}:</span>
<span
className="badge"
style={{
backgroundColor: formData.badge_color,
color: '#fff',
fontSize: '0.9rem',
}}
>
{formData.badge_text || `-${formData.discount_value}%`}
</span>
</div>
</>
)}
</div>
</div>
{/* Ativo */}
<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" htmlFor="is_active">
{t('campaigns.form.isActive')}
</label>
</div>
</div>
)}
{/* Step 2: Select Products */}
{activeStep === 2 && (
<div className="step-content">
<div className="form-check mb-4">
<input
type="checkbox"
className="form-check-input"
id="apply_to_all_products"
name="apply_to_all_products"
checked={formData.apply_to_all_products}
onChange={handleChange}
/>
<label className="form-check-label" htmlFor="apply_to_all_products">
<strong>{t('campaigns.form.applyToAll')}</strong>
<small className="d-block text-slate-400">{t('campaigns.form.applyToAllHelp')}</small>
</label>
</div>
{!formData.apply_to_all_products && (
<>
<div className="d-flex justify-content-between align-items-center mb-3">
<span className="text-slate-400">
{formData.product_ids.length} {t('campaigns.productsSelected')}
</span>
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={handleSelectAllProducts}
>
{formData.product_ids.length === sheets.length
? t('campaigns.deselectAll')
: t('campaigns.selectAll')}
</button>
</div>
<div className="products-list" style={{ maxHeight: '400px', overflowY: 'auto' }}>
{sheets.map(sheet => (
<div
key={sheet.id}
className={`d-flex align-items-center justify-content-between p-2 rounded mb-2 cursor-pointer ${
formData.product_ids.includes(sheet.id)
? 'bg-primary bg-opacity-25 border border-primary'
: 'bg-secondary bg-opacity-25'
}`}
onClick={() => handleProductToggle(sheet.id)}
style={{ cursor: 'pointer' }}
>
<div className="d-flex align-items-center gap-2">
<input
type="checkbox"
className="form-check-input"
checked={formData.product_ids.includes(sheet.id)}
onChange={() => {}}
/>
<div>
<div className="text-white">{sheet.name}</div>
<small className="text-slate-400">{sheet.category}</small>
</div>
</div>
<div className="text-end">
<div className="text-white">{currency(sheet.final_price || sheet.sale_price)}</div>
<small className="text-success">
{currency(parseFloat(sheet.final_price || sheet.sale_price) * (1 - parseFloat(formData.discount_value) / 100))}
</small>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
{/* Step 3: Review */}
{activeStep === 3 && (
<div className="step-content">
<div className="card bg-secondary bg-opacity-25 mb-3">
<div className="card-body">
<h6 className="text-white mb-3">{t('campaigns.review.summary')}</h6>
<div className="row g-3">
<div className="col-6">
<small className="text-slate-400">{t('campaigns.form.name')}</small>
<div className="text-white">{formData.name}</div>
</div>
<div className="col-6">
<small className="text-slate-400">{t('campaigns.discount')}</small>
<div className="text-white">
{formData.discount_type === 'percentage' && `-${formData.discount_value}%`}
{formData.discount_type === 'fixed' && `-${currency(formData.discount_value)}`}
{formData.discount_type === 'price_override' && currency(formData.discount_value)}
</div>
</div>
<div className="col-6">
<small className="text-slate-400">{t('campaigns.period')}</small>
<div className="text-white">{formData.start_date} - {formData.end_date}</div>
</div>
<div className="col-6">
<small className="text-slate-400">{t('campaigns.products')}</small>
<div className="text-white">
{formData.apply_to_all_products
? t('campaigns.allProducts')
: `${formData.product_ids.length} ${t('campaigns.productsSelected')}`}
</div>
</div>
</div>
</div>
</div>
{/* Preview Results */}
{preview && (
<>
{/* Alertas de Rentabilidade */}
{preview.totals?.unprofitable_count > 0 && (
<div className="alert alert-danger d-flex align-items-center mb-3">
<i className="bi bi-exclamation-triangle-fill me-2"></i>
<div>
<strong>{t('campaigns.review.profitWarning')}</strong>
<div className="small">
{t('campaigns.review.unprofitableProducts', { count: preview.totals.unprofitable_count })}
</div>
</div>
</div>
)}
{preview.totals?.protected_count > 0 && (
<div className="alert alert-info d-flex align-items-center mb-3">
<i className="bi bi-shield-check me-2"></i>
<div>
<strong>{t('campaigns.review.pricesProtected')}</strong>
<div className="small">
{t('campaigns.review.protectedProducts', { count: preview.totals.protected_count })}
</div>
</div>
</div>
)}
<div className="card bg-secondary bg-opacity-25 mb-3">
<div className="card-body">
<h6 className="text-white mb-3">{t('campaigns.review.priceImpact')}</h6>
<div className="row g-3 mb-3">
<div className="col-3">
<div className="text-center">
<small className="text-slate-400">{t('campaigns.review.totalOriginal')}</small>
<div className="h5 text-white mb-0">{currency(preview.totals?.total_original || 0)}</div>
</div>
</div>
<div className="col-3">
<div className="text-center">
<small className="text-slate-400">{t('campaigns.review.totalPromo')}</small>
<div className="h5 text-success mb-0">{currency(preview.totals?.total_promotional || 0)}</div>
</div>
</div>
<div className="col-3">
<div className="text-center">
<small className="text-slate-400">{t('campaigns.review.avgDiscount')}</small>
<div className="h5 text-warning mb-0">-{(preview.totals?.avg_discount || 0).toFixed(1)}%</div>
</div>
</div>
<div className="col-3">
<div className="text-center">
<small className="text-slate-400">{t('campaigns.review.totalCmv')}</small>
<div className="h5 text-slate-300 mb-0">{currency(preview.totals?.total_cmv || 0)}</div>
</div>
</div>
</div>
</div>
</div>
{/* Análise de Rentabilidade */}
<div className="card bg-success bg-opacity-10 border-success mb-3">
<div className="card-body">
<h6 className="text-success mb-3">
<i className="bi bi-graph-up-arrow me-2"></i>
{t('campaigns.review.profitAnalysis')}
</h6>
<div className="row g-3">
<div className="col-4">
<div className="text-center">
<small className="text-slate-400">{t('campaigns.review.totalProfit')}</small>
<div className={`h5 mb-0 ${(preview.totals?.total_promo_margin || 0) >= 0 ? 'text-success' : 'text-danger'}`}>
{currency(preview.totals?.total_promo_margin || 0)}
</div>
</div>
</div>
<div className="col-4">
<div className="text-center">
<small className="text-slate-400">{t('campaigns.review.avgPromoMargin')}</small>
<div className={`h5 mb-0 ${(preview.totals?.avg_promo_margin_percent || 0) >= 10 ? 'text-success' : 'text-warning'}`}>
{(preview.totals?.avg_promo_margin_percent || 0).toFixed(1)}%
</div>
</div>
</div>
<div className="col-4">
<div className="text-center">
<small className="text-slate-400">{t('campaigns.review.originalMargin')}</small>
<div className="h5 text-slate-300 mb-0">
{(preview.totals?.avg_original_margin_percent || 0).toFixed(1)}%
</div>
</div>
</div>
</div>
</div>
</div>
{preview.products && preview.products.length > 0 && (
<div className="card bg-secondary bg-opacity-25">
<div className="card-body">
<h6 className="text-white mb-3">{t('campaigns.review.productDetails')}</h6>
<div className="table-responsive" style={{ maxHeight: '250px' }}>
<table className="table table-sm table-dark mb-0">
<thead className="sticky-top bg-dark">
<tr>
<th>{t('campaigns.review.product')}</th>
<th className="text-end">{t('campaigns.review.cmv')}</th>
<th className="text-end">{t('campaigns.review.original')}</th>
<th className="text-end">{t('campaigns.review.promo')}</th>
<th className="text-end">{t('campaigns.review.margin')}</th>
<th className="text-center">{t('campaigns.review.status')}</th>
</tr>
</thead>
<tbody>
{preview.products.slice(0, 15).map(p => (
<tr key={p.id} className={!p.is_profitable ? 'table-danger' : ''}>
<td>
{p.name}
{p.was_protected && (
<i className="bi bi-shield-check text-info ms-1" title={p.protection_reason}></i>
)}
</td>
<td className="text-end text-slate-400">{currency(p.cmv)}</td>
<td className="text-end text-decoration-line-through text-slate-400">
{currency(p.original_price)}
</td>
<td className="text-end text-success">{currency(p.promotional_price)}</td>
<td className={`text-end ${p.promo_margin_percent >= 10 ? 'text-success' : p.promo_margin_percent >= 0 ? 'text-warning' : 'text-danger'}`}>
{p.promo_margin_percent.toFixed(1)}%
</td>
<td className="text-center">
{p.is_profitable ? (
<span className="badge bg-success">
<i className="bi bi-check-circle"></i>
</span>
) : (
<span className="badge bg-danger">
<i className="bi bi-exclamation-triangle"></i>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{preview.products.length > 15 && (
<small className="text-slate-400">
{t('campaigns.review.andMore', { count: preview.products.length - 15 })}
</small>
)}
</div>
</div>
</div>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
<div className="modal-footer border-secondary">
<button type="button" className="btn btn-secondary" onClick={onClose}>
{t('common.cancel')}
</button>
{activeStep > 1 && (
<button
type="button"
className="btn btn-outline-primary"
onClick={() => setActiveStep(prev => prev - 1)}
>
<i className="bi bi-arrow-left me-1"></i>
{t('common.back')}
</button>
)}
{activeStep < 3 ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setActiveStep(prev => prev + 1)}
disabled={!isStepValid}
>
{t('common.next')}
<i className="bi bi-arrow-right ms-1"></i>
</button>
) : (
<button
type="button"
className="btn btn-success"
onClick={handleSubmit}
disabled={loading || !isStepValid}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-1"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-1"></i>
{campaign?.id ? t('common.save') : t('campaigns.create')}
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default CampaignModal;

View File

@ -0,0 +1,327 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { campaignService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
import CampaignModal from './CampaignModal';
const CampaignsTab = ({ sheets }) => {
const { t } = useTranslation();
const { currency, date: formatDate } = useFormatters();
const [campaigns, setCampaigns] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [presets, setPresets] = useState({});
const [modalOpen, setModalOpen] = useState(false);
const [editingCampaign, setEditingCampaign] = useState(null);
const [statusFilter, setStatusFilter] = useState('all');
// Carregar dados
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [campaignsData, presetsData] = await Promise.all([
campaignService.getAll(statusFilter !== 'all' ? { status: statusFilter } : {}),
campaignService.getPresets(),
]);
setCampaigns(campaignsData);
setPresets(presetsData);
} catch (err) {
console.error('Error loading campaigns:', err);
setError(err.response?.data?.message || t('common.error'));
} finally {
setLoading(false);
}
}, [t, statusFilter]);
useEffect(() => {
loadData();
}, [loadData]);
const handleCreate = () => {
setEditingCampaign(null);
setModalOpen(true);
};
const handleEdit = (campaign) => {
setEditingCampaign(campaign);
setModalOpen(true);
};
const handleDuplicate = async (campaign) => {
try {
const duplicated = await campaignService.duplicate(campaign.id);
setCampaigns(prev => [duplicated, ...prev]);
} catch (err) {
setError(err.response?.data?.message || t('common.error'));
}
};
const handleDelete = async (campaign) => {
if (!window.confirm(t('campaigns.deleteConfirm', { name: campaign.name }))) {
return;
}
try {
await campaignService.delete(campaign.id);
setCampaigns(prev => prev.filter(c => c.id !== campaign.id));
} catch (err) {
setError(err.response?.data?.message || t('common.error'));
}
};
const handleSaved = (campaign) => {
if (editingCampaign) {
setCampaigns(prev => prev.map(c => c.id === campaign.id ? campaign : c));
} else {
setCampaigns(prev => [campaign, ...prev]);
}
setModalOpen(false);
setEditingCampaign(null);
};
const getStatusBadge = (campaign) => {
const statusConfig = {
active: { class: 'bg-success', icon: 'bi-check-circle', label: t('campaigns.status.active') },
scheduled: { class: 'bg-info', icon: 'bi-clock', label: t('campaigns.status.scheduled') },
ended: { class: 'bg-secondary', icon: 'bi-x-circle', label: t('campaigns.status.ended') },
inactive: { class: 'bg-warning', icon: 'bi-pause-circle', label: t('campaigns.status.inactive') },
};
const config = statusConfig[campaign.status] || statusConfig.inactive;
return (
<span className={`badge ${config.class} d-flex align-items-center gap-1`}>
<i className={`bi ${config.icon}`}></i>
{config.label}
</span>
);
};
const getDiscountDisplay = (campaign) => {
switch (campaign.discount_type) {
case 'percentage':
return `-${parseFloat(campaign.discount_value).toFixed(0)}%`;
case 'fixed':
return `-${currency(campaign.discount_value)}`;
case 'price_override':
return currency(campaign.discount_value);
default:
return '-';
}
};
// Quick preset buttons
const quickPresets = [
{ key: 'black_friday', icon: 'bi-tag-fill', color: '#000000' },
{ key: 'cyber_monday', icon: 'bi-laptop', color: '#0ea5e9' },
{ key: 'christmas', icon: 'bi-gift', color: '#dc2626' },
{ key: 'flash_sale', icon: 'bi-lightning-charge-fill', color: '#eab308' },
];
return (
<div className="campaigns-tab">
{/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div className="d-flex align-items-center gap-3">
<h5 className="mb-0 text-white">
<i className="bi bi-megaphone me-2"></i>
{t('campaigns.title')}
</h5>
<span className="badge bg-primary">{campaigns.length}</span>
</div>
<div className="d-flex gap-2">
{/* Status Filter */}
<select
className="form-select form-select-sm bg-dark text-white"
style={{ width: '150px', borderColor: 'rgba(255,255,255,0.2)' }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">{t('campaigns.filter.all')}</option>
<option value="active">{t('campaigns.filter.active')}</option>
<option value="scheduled">{t('campaigns.filter.scheduled')}</option>
<option value="ended">{t('campaigns.filter.ended')}</option>
<option value="inactive">{t('campaigns.filter.inactive')}</option>
</select>
<button className="btn btn-primary btn-sm" onClick={handleCreate}>
<i className="bi bi-plus-lg me-1"></i>
{t('campaigns.new')}
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="alert alert-danger d-flex align-items-center mb-4">
<i className="bi bi-exclamation-triangle me-2"></i>
{error}
<button className="btn-close ms-auto" onClick={() => setError(null)}></button>
</div>
)}
{/* Quick Presets */}
<div className="card bg-dark border-secondary mb-4">
<div className="card-body">
<h6 className="text-white mb-3">
<i className="bi bi-lightning me-2"></i>
{t('campaigns.quickStart')}
</h6>
<div className="d-flex flex-wrap gap-2">
{quickPresets.map(preset => (
<button
key={preset.key}
className="btn btn-outline-light btn-sm d-flex align-items-center gap-2"
onClick={() => {
setEditingCampaign({ preset: preset.key });
setModalOpen(true);
}}
style={{ borderColor: preset.color, color: preset.color }}
>
<i className={`bi ${preset.icon}`}></i>
{presets[preset.key]?.name || preset.key}
</button>
))}
</div>
</div>
</div>
{/* Campaigns List */}
{loading ? (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
) : campaigns.length === 0 ? (
<div className="text-center py-5">
<i className="bi bi-megaphone text-slate-500" style={{ fontSize: '3rem' }}></i>
<p className="text-slate-400 mt-3">{t('campaigns.empty')}</p>
<button className="btn btn-primary" onClick={handleCreate}>
<i className="bi bi-plus-lg me-1"></i>
{t('campaigns.createFirst')}
</button>
</div>
) : (
<div className="row g-3">
{campaigns.map(campaign => (
<div key={campaign.id} className="col-md-6 col-lg-4">
<div
className="card bg-dark h-100"
style={{
borderColor: campaign.badge_color || 'rgba(255,255,255,0.1)',
borderWidth: campaign.is_currently_active ? '2px' : '1px',
}}
>
<div className="card-header d-flex justify-content-between align-items-center py-2">
<div className="d-flex align-items-center gap-2">
{campaign.show_discount_badge && (
<span
className="badge"
style={{
backgroundColor: campaign.badge_color || '#ef4444',
color: '#fff',
}}
>
{campaign.badge_text || getDiscountDisplay(campaign)}
</span>
)}
{getStatusBadge(campaign)}
</div>
<div className="dropdown">
<button
className="btn btn-sm text-slate-400"
data-bs-toggle="dropdown"
>
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end dropdown-menu-dark">
<li>
<button className="dropdown-item" onClick={() => handleEdit(campaign)}>
<i className="bi bi-pencil me-2"></i>
{t('common.edit')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => handleDuplicate(campaign)}>
<i className="bi bi-copy me-2"></i>
{t('common.duplicate')}
</button>
</li>
<li><hr className="dropdown-divider" /></li>
<li>
<button
className="dropdown-item text-danger"
onClick={() => handleDelete(campaign)}
>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</div>
<div className="card-body">
<h6 className="text-white mb-2">{campaign.name}</h6>
{campaign.description && (
<p className="text-slate-400 small mb-3">{campaign.description}</p>
)}
{/* Desconto */}
<div className="d-flex justify-content-between align-items-center mb-2">
<span className="text-slate-400 small">{t('campaigns.discount')}:</span>
<span className="text-white fw-bold">{getDiscountDisplay(campaign)}</span>
</div>
{/* Período */}
<div className="d-flex justify-content-between align-items-center mb-2">
<span className="text-slate-400 small">{t('campaigns.period')}:</span>
<span className="text-white small">
{formatDate(campaign.start_date)} - {formatDate(campaign.end_date)}
</span>
</div>
{/* Produtos */}
<div className="d-flex justify-content-between align-items-center">
<span className="text-slate-400 small">{t('campaigns.products')}:</span>
<span className="text-white">
{campaign.products_count || 0}
{campaign.apply_to_all_products && (
<span className="badge bg-info ms-1">{t('campaigns.allProducts')}</span>
)}
</span>
</div>
{/* Dias restantes */}
{campaign.is_currently_active && campaign.days_remaining !== null && (
<div className="mt-3 pt-3 border-top border-secondary">
<div className="d-flex justify-content-between align-items-center">
<span className="text-slate-400 small">{t('campaigns.daysRemaining')}:</span>
<span className={`fw-bold ${campaign.days_remaining <= 3 ? 'text-warning' : 'text-success'}`}>
{campaign.days_remaining} {t('campaigns.days')}
</span>
</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Modal */}
<CampaignModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
setEditingCampaign(null);
}}
onSaved={handleSaved}
campaign={editingCampaign}
presets={presets}
sheets={sheets}
/>
</div>
);
};
export default CampaignsTab;

View File

@ -99,7 +99,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
>
{settings.map(s => (
<option key={s.id} value={s.id}>
{s.name} ({s.currency}) - Markup: {s.markup_factor?.toFixed(2)}
{s.name} ({s.currency}) - {t('business.common.markupLabel')}: {parseFloat(s.markup_factor || 0).toFixed(2)}
</option>
))}
</select>
@ -138,7 +138,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
</span>
</div>
<div className="col-6">
<small className="text-slate-500 d-block">× Markup</small>
<small className="text-slate-500 d-block">{t('business.common.timesMarkup')}</small>
<span className="text-primary fw-bold">{localResult.markup.toFixed(2)}</span>
</div>
<div className="col-12">
@ -170,7 +170,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
<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
{t('business.calculator.salePrice')} = {t('business.common.cmvLabel')} × {t('business.common.markupLabel')}
</code>
<br />
<code className="text-slate-400 small">
@ -195,7 +195,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
{/* 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>
<h2 className="text-primary mb-0">{parseFloat(selectedSetting.markup_factor || 0).toFixed(4)}</h2>
</div>
{/* Detalhes */}
@ -210,7 +210,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
</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>
<span className="text-warning">{parseFloat(selectedSetting.fixed_expenses_rate || 0).toFixed(2)}%</span>
</div>
</div>
@ -236,7 +236,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
)}
<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>
<span className="text-warning">{parseFloat(selectedSetting.total_variable_costs || 0).toFixed(2)}%</span>
</div>
</div>
@ -256,7 +256,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
<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)}%
{(parseFloat(selectedSetting.fixed_expenses_rate || 0) + parseFloat(selectedSetting.total_variable_costs || 0) + parseFloat(selectedSetting.investment_rate || 0) + parseFloat(selectedSetting.profit_margin || 0)).toFixed(2)}%
</span>
</div>
</div>

View File

@ -16,11 +16,20 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
currency: 'EUR',
business_setting_id: '',
is_active: true,
// Strategic pricing fields
competitor_price: '',
min_price: '',
max_price: '',
premium_multiplier: '1.00',
price_strategy: 'neutral',
psychological_rounding: false,
target_margin_percent: '',
});
const [items, setItems] = useState([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [showStrategicPricing, setShowStrategicPricing] = useState(false);
const itemTypes = [
{ value: 'product_cost', label: t('business.products.itemTypes.productCost') },
@ -41,6 +50,14 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
currency: sheet.currency || 'EUR',
business_setting_id: sheet.business_setting_id || '',
is_active: sheet.is_active ?? true,
// Strategic pricing fields
competitor_price: sheet.competitor_price || '',
min_price: sheet.min_price || '',
max_price: sheet.max_price || '',
premium_multiplier: sheet.premium_multiplier || '1.00',
price_strategy: sheet.price_strategy || 'neutral',
psychological_rounding: sheet.psychological_rounding ?? false,
target_margin_percent: sheet.target_margin_percent || '',
});
setItems(sheet.items?.map(item => ({
id: item.id,
@ -50,6 +67,11 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
quantity: item.quantity || 1,
unit: item.unit || '',
})) || []);
// Show strategic pricing if any field is set
if (sheet.competitor_price || sheet.min_price || sheet.max_price ||
sheet.target_margin_percent || sheet.price_strategy !== 'neutral') {
setShowStrategicPricing(true);
}
}
}, [sheet]);
@ -130,6 +152,14 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
currency: formData.currency,
business_setting_id: formData.business_setting_id || null,
is_active: formData.is_active,
// Strategic pricing fields
competitor_price: formData.competitor_price ? parseFloat(formData.competitor_price) : null,
min_price: formData.min_price ? parseFloat(formData.min_price) : null,
max_price: formData.max_price ? parseFloat(formData.max_price) : null,
premium_multiplier: parseFloat(formData.premium_multiplier) || 1.0,
price_strategy: formData.price_strategy,
psychological_rounding: formData.psychological_rounding,
target_margin_percent: formData.target_margin_percent ? parseFloat(formData.target_margin_percent) : null,
});
// Remover itens que não existem mais
@ -185,8 +215,8 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
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-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" style={{ maxHeight: '90vh' }}>
<div className="modal-content" style={{ background: '#1e293b', border: 'none', maxHeight: '90vh' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className="bi bi-box-seam me-2"></i>
@ -196,7 +226,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="modal-body" style={{ maxHeight: 'calc(90vh - 130px)', overflowY: 'auto' }}>
{error && (
<div className="alert alert-danger">{typeof error === 'string' ? error : JSON.stringify(error)}</div>
)}
@ -223,7 +253,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
name="sku"
value={formData.sku}
onChange={handleChange}
placeholder="ABC-001"
placeholder={t('business.products.skuPlaceholder')}
/>
</div>
@ -263,7 +293,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
<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)})
{s.name} (Markup: {parseFloat(s.markup_factor || 0).toFixed(2)})
</option>
))}
</select>
@ -280,6 +310,141 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
/>
</div>
{/* Strategic Pricing Toggle */}
<div className="col-12">
<hr className="border-secondary my-2" />
<button
type="button"
className="btn btn-sm btn-outline-info w-100"
onClick={() => setShowStrategicPricing(!showStrategicPricing)}
>
<i className={`bi bi-chevron-${showStrategicPricing ? 'up' : 'down'} me-2`}></i>
{t('business.products.strategicPricing')}
<i className="bi bi-graph-up ms-2"></i>
</button>
</div>
{/* Strategic Pricing Fields */}
{showStrategicPricing && (
<>
<div className="col-md-4">
<label className="form-label text-slate-400">
<i className="bi bi-building me-1"></i>
{t('business.products.competitorPrice')}
</label>
<input
type="number"
step="0.01"
min="0"
className="form-control bg-dark text-white border-secondary"
name="competitor_price"
value={formData.competitor_price}
onChange={handleChange}
placeholder="0.00"
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">
<i className="bi bi-arrow-down me-1"></i>
{t('business.products.minPrice')}
</label>
<input
type="number"
step="0.01"
min="0"
className="form-control bg-dark text-white border-secondary"
name="min_price"
value={formData.min_price}
onChange={handleChange}
placeholder="0.00"
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">
<i className="bi bi-arrow-up me-1"></i>
{t('business.products.maxPrice')}
</label>
<input
type="number"
step="0.01"
min="0"
className="form-control bg-dark text-white border-secondary"
name="max_price"
value={formData.max_price}
onChange={handleChange}
placeholder="0.00"
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">
<i className="bi bi-star me-1"></i>
{t('business.products.premiumMultiplier')}
</label>
<input
type="number"
step="0.01"
min="0.1"
max="5"
className="form-control bg-dark text-white border-secondary"
name="premium_multiplier"
value={formData.premium_multiplier}
onChange={handleChange}
/>
<small className="text-slate-500">1.0 = {t('business.products.neutral')}</small>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">
<i className="bi bi-bullseye me-1"></i>
{t('business.products.priceStrategy')}
</label>
<select
className="form-select bg-dark text-white border-secondary"
name="price_strategy"
value={formData.price_strategy}
onChange={handleChange}
>
<option value="aggressive">{t('business.products.strategyAggressive')}</option>
<option value="neutral">{t('business.products.strategyNeutral')}</option>
<option value="premium">{t('business.products.strategyPremium')}</option>
</select>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">
<i className="bi bi-percent me-1"></i>
{t('business.products.targetMargin')}
</label>
<input
type="number"
step="0.1"
min="0"
max="99"
className="form-control bg-dark text-white border-secondary"
name="target_margin_percent"
value={formData.target_margin_percent}
onChange={handleChange}
placeholder={t('business.products.useGlobal')}
/>
</div>
<div className="col-12">
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="psychological_rounding"
name="psychological_rounding"
checked={formData.psychological_rounding}
onChange={handleChange}
/>
<label className="form-check-label text-slate-400" htmlFor="psychological_rounding">
<i className="bi bi-magic me-1"></i>
{t('business.products.psychologicalRounding')}
<small className="text-slate-500 ms-2">({t('business.products.psychologicalExample')})</small>
</label>
</div>
</div>
</>
)}
{/* Componentes de Custo (CMV) */}
<div className="col-12">
<hr className="border-secondary my-2" />
@ -383,7 +548,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
<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>
<small className="text-slate-500 d-block mb-1">{t('business.products.cmvTotalLabel')}</small>
<h4 className="text-danger mb-0">{currency(cmvTotal, formData.currency)}</h4>
</div>
</div>

View File

@ -147,7 +147,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
<span className="badge bg-secondary ms-2">{t('common.inactive')}</span>
)}
</h6>
{sheet.sku && <small className="text-slate-500">SKU: {sheet.sku}</small>}
{sheet.sku && <small className="text-slate-500">{t('business.common.skuLabel')}: {sheet.sku}</small>}
{sheet.category && (
<span className="badge bg-secondary ms-2" style={{ fontSize: '10px' }}>{sheet.category}</span>
)}
@ -189,7 +189,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
<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>
<small className="text-slate-500 d-block">{t('business.common.cmvLabel')}</small>
<span className="text-danger fw-bold">
{currency(sheet.cmv_total, sheet.currency)}
</span>
@ -203,6 +203,17 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
</span>
</div>
</div>
{/* Final Price (if strategic pricing applied) */}
{sheet.final_price && parseFloat(sheet.final_price) !== parseFloat(sheet.sale_price) && (
<div className="col-4 text-end">
<div className="p-2 rounded" style={{ background: 'rgba(147, 51, 234, 0.1)' }}>
<small className="text-slate-500 d-block">{t('business.products.finalPrice')}</small>
<span className="text-purple fw-bold" style={{ color: '#a855f7' }}>
{currency(sheet.final_price, sheet.currency)}
</span>
</div>
</div>
)}
</div>
{/* Margem de Contribuição */}
@ -212,7 +223,55 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
<span className="text-info">
{currency(sheet.contribution_margin, sheet.currency)}
<span className="text-slate-500 ms-1">({sheet.contribution_margin_percent}%)</span>
{sheet.real_margin_percent && parseFloat(sheet.real_margin_percent) !== parseFloat(sheet.contribution_margin_percent) && (
<span className="ms-2 text-purple" style={{ color: '#a855f7' }}>
{parseFloat(sheet.real_margin_percent).toFixed(1)}%
</span>
)}
</span>
</div>
)}
{/* Competitor Comparison */}
{sheet.competitor_comparison && (
<div className="d-flex justify-content-between align-items-center small mb-3 p-2 rounded"
style={{ background: sheet.competitor_comparison.position === 'below' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)' }}>
<span className="text-slate-500">
<i className="bi bi-building me-1"></i>
{t('business.products.competitorComparison')}
</span>
<span className={sheet.competitor_comparison.position === 'below' ? 'text-success' : 'text-danger'}>
{currency(sheet.competitor_comparison.our_price, sheet.currency)}
<span className="ms-1">
({sheet.competitor_comparison.percent_difference > 0 ? '+' : ''}{sheet.competitor_comparison.percent_difference.toFixed(1)}%)
</span>
</span>
</div>
)}
{/* Strategic Badges */}
{(sheet.price_strategy !== 'neutral' || sheet.psychological_rounding || sheet.target_margin_percent) && (
<div className="mb-3">
{sheet.price_strategy === 'aggressive' && (
<span className="badge bg-danger me-1">
<i className="bi bi-lightning me-1"></i>{t('business.products.strategyAggressiveLabel')}
</span>
)}
{sheet.price_strategy === 'premium' && (
<span className="badge bg-warning text-dark me-1">
<i className="bi bi-star me-1"></i>{t('business.products.strategyPremiumLabel')}
</span>
)}
{sheet.psychological_rounding && (
<span className="badge bg-info me-1">
<i className="bi bi-magic me-1"></i>{t('business.products.psychologicalBadge')}
</span>
)}
{sheet.target_margin_percent && (
<span className="badge bg-secondary me-1">
<i className="bi bi-percent me-1"></i>{sheet.target_margin_percent}%
</span>
)}
</div>
)}
@ -239,7 +298,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
{sheet.business_setting.name}
{sheet.markup_used && (
<span className="ms-2">
(Markup: {sheet.markup_used.toFixed(2)})
({t('business.common.markupLabel')}: {parseFloat(sheet.markup_used).toFixed(2)})
</span>
)}
</small>

View File

@ -0,0 +1,713 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { serviceSheetService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
const ServiceSheetModal = ({ sheet, settings, onSave, onClose }) => {
const { t } = useTranslation();
const { currency } = useFormatters();
const isEditing = !!sheet;
const [formData, setFormData] = useState({
name: '',
code: '',
description: '',
category: '',
business_setting_id: '',
duration_minutes: '30',
is_active: true,
// Strategic pricing fields
competitor_price: '',
min_price: '',
max_price: '',
premium_multiplier: '1.00',
pricing_strategy: 'neutral',
psychological_pricing: false,
target_margin: '',
});
const [items, setItems] = useState([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [showStrategicPricing, setShowStrategicPricing] = useState(false);
const itemTypes = [
{ value: 'supply', label: t('business.services.itemTypes.supply') },
{ value: 'consumable', label: t('business.services.itemTypes.consumable') },
{ value: 'material', label: t('business.services.itemTypes.material') },
{ value: 'equipment_usage', label: t('business.services.itemTypes.equipmentUsage') },
{ value: 'other', label: t('business.services.itemTypes.other') },
];
useEffect(() => {
if (sheet) {
setFormData({
name: sheet.name || '',
code: sheet.code || '',
description: sheet.description || '',
category: sheet.category || '',
business_setting_id: sheet.business_setting_id || '',
duration_minutes: sheet.duration_minutes || '30',
is_active: sheet.is_active ?? true,
// Strategic pricing fields
competitor_price: sheet.competitor_price || '',
min_price: sheet.min_price || '',
max_price: sheet.max_price || '',
premium_multiplier: sheet.premium_multiplier || '1.00',
pricing_strategy: sheet.pricing_strategy || 'neutral',
psychological_pricing: sheet.psychological_pricing ?? false,
target_margin: sheet.target_margin || '',
});
setItems(sheet.items?.map(item => ({
id: item.id,
name: item.name,
type: item.type,
unit_cost: item.unit_cost,
quantity_used: item.quantity_used || 1,
unit: item.unit || '',
})) || []);
// Show strategic pricing if any field is set
if (sheet.competitor_price || sheet.min_price || sheet.max_price ||
sheet.target_margin || sheet.pricing_strategy !== 'neutral') {
setShowStrategicPricing(true);
}
} else if (settings.length > 0) {
// Auto-select first service-enabled setting
setFormData(prev => ({
...prev,
business_setting_id: settings[0].id.toString(),
}));
}
}, [sheet, settings]);
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: 'supply',
unit_cost: '',
quantity_used: 1,
unit: '',
}]);
};
const removeItem = (index) => {
setItems(prev => prev.filter((_, i) => i !== index));
};
// Calcular CSV total (Custo do Serviço Vendido)
const calculateCsv = () => {
return items.reduce((sum, item) => {
const cost = parseFloat(item.unit_cost) || 0;
const quantity = parseFloat(item.quantity_used) || 1;
return sum + (cost * quantity);
}, 0);
};
// Calcular preview del precio
const calculatePreview = () => {
if (!formData.business_setting_id) return null;
const setting = settings.find(s => s.id === parseInt(formData.business_setting_id));
if (!setting) return null;
const duration = parseFloat(formData.duration_minutes) || 30;
const csv = calculateCsv();
// Gasto fijo por minuto
const employees = parseInt(setting.employees_count) || 1;
const hoursPerDay = parseFloat(setting.hours_per_day) || 8;
const workingDays = parseInt(setting.working_days_per_month) || 22;
const productivity = parseFloat(setting.productivity_rate) || 80;
const productiveHours = employees * hoursPerDay * workingDays * (productivity / 100);
const fixedExpenses = parseFloat(setting.fixed_expenses) || 0;
const fixedCostPerHour = productiveHours > 0 ? fixedExpenses / productiveHours : 0;
const fixedCostPerMinute = fixedCostPerHour / 60;
// Porção de custo fixo proporcional ao tempo
const fixedCostPortion = fixedCostPerMinute * duration;
// Preço base = (Gasto Fixo + CSV) × Markup
const markup = parseFloat(setting.markup_factor) || 1;
const priceWithoutTax = (fixedCostPortion + csv) * markup;
// Se B2C, adiciona IVA
let finalPrice = priceWithoutTax;
let vatAmount = 0;
if (setting.price_includes_tax) {
const vatRate = parseFloat(setting.vat_rate) || 0;
finalPrice = priceWithoutTax * (1 + vatRate / 100);
vatAmount = finalPrice - priceWithoutTax;
}
return {
duration,
csv,
fixedCostPortion,
baseCost: fixedCostPortion + csv,
markup,
priceWithoutTax,
vatRate: setting.price_includes_tax ? parseFloat(setting.vat_rate) : 0,
vatAmount,
finalPrice,
fixedCostPerHour,
currency: setting.currency,
};
};
const preview = calculatePreview();
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
// Validar itens
const validItems = items.filter(item => item.name && item.unit_cost);
try {
const dataToSend = {
...formData,
business_setting_id: parseInt(formData.business_setting_id),
duration_minutes: parseFloat(formData.duration_minutes) || 30,
competitor_price: formData.competitor_price ? parseFloat(formData.competitor_price) : null,
min_price: formData.min_price ? parseFloat(formData.min_price) : null,
max_price: formData.max_price ? parseFloat(formData.max_price) : null,
premium_multiplier: parseFloat(formData.premium_multiplier) || 1,
target_margin: formData.target_margin ? parseFloat(formData.target_margin) : null,
items: validItems.map(item => ({
name: item.name,
type: item.type,
unit_cost: parseFloat(item.unit_cost) || 0,
quantity_used: parseFloat(item.quantity_used) || 1,
unit: item.unit || null,
})),
};
let result;
if (isEditing) {
// Para edição, atualizar ficha e depois itens
result = await serviceSheetService.update(sheet.id, {
name: formData.name,
code: formData.code,
description: formData.description,
category: formData.category,
business_setting_id: dataToSend.business_setting_id,
duration_minutes: dataToSend.duration_minutes,
is_active: formData.is_active,
competitor_price: dataToSend.competitor_price,
min_price: dataToSend.min_price,
max_price: dataToSend.max_price,
premium_multiplier: dataToSend.premium_multiplier,
pricing_strategy: formData.pricing_strategy,
psychological_pricing: formData.psychological_pricing,
target_margin: dataToSend.target_margin,
});
// Gerenciar itens
const existingIds = items.filter(i => i.id).map(i => i.id);
const itemsToRemove = sheet.items?.filter(i => !existingIds.includes(i.id)) || [];
for (const item of itemsToRemove) {
await serviceSheetService.removeItem(sheet.id, item.id);
}
for (const item of validItems) {
if (item.id) {
await serviceSheetService.updateItem(sheet.id, item.id, {
name: item.name,
type: item.type,
unit_cost: parseFloat(item.unit_cost) || 0,
quantity_used: parseFloat(item.quantity_used) || 1,
unit: item.unit || null,
});
} else {
await serviceSheetService.addItem(sheet.id, {
name: item.name,
type: item.type,
unit_cost: parseFloat(item.unit_cost) || 0,
quantity_used: parseFloat(item.quantity_used) || 1,
unit: item.unit || null,
});
}
}
// Reload to get updated data
result = await serviceSheetService.get(sheet.id);
} else {
result = await serviceSheetService.create(dataToSend);
}
onSave(result);
} catch (err) {
setError(err.response?.data?.message || err.response?.data?.errors || t('common.error'));
} finally {
setSaving(false);
}
};
const selectedSetting = settings.find(s => s.id === parseInt(formData.business_setting_id));
return (
<div className="modal show d-block" style={{ background: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" style={{ maxHeight: '90vh' }}>
<div className="modal-content" style={{ background: '#1e293b', border: 'none', maxHeight: '90vh' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className="bi bi-person-workspace me-2 text-success"></i>
{isEditing ? t('business.services.edit') : t('business.services.add')}
</h5>
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body" style={{ maxHeight: 'calc(90vh - 130px)', overflowY: 'auto' }}>
{error && (
<div className="alert alert-danger">{typeof error === 'string' ? error : JSON.stringify(error)}</div>
)}
<div className="row g-3">
{/* Información básica */}
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.services.name')}</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t('business.services.namePlaceholder')}
required
/>
</div>
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.services.code')}</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="code"
value={formData.code}
onChange={handleChange}
placeholder={t('business.services.codePlaceholder')}
/>
</div>
<div className="col-md-3">
<label className="form-label text-slate-400">{t('business.services.duration')}</label>
<div className="input-group">
<input
type="number"
min="1"
className="form-control bg-dark text-white border-secondary"
name="duration_minutes"
value={formData.duration_minutes}
onChange={handleChange}
required
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">{t('business.common.minuteShort')}</span>
</div>
</div>
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.services.category')}</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="category"
value={formData.category}
onChange={handleChange}
placeholder={t('business.services.categoryPlaceholder')}
/>
</div>
<div className="col-md-6">
<label className="form-label text-slate-400">{t('business.services.businessSetting')}</label>
<select
className="form-select bg-dark text-white border-secondary"
name="business_setting_id"
value={formData.business_setting_id}
onChange={handleChange}
required
>
<option value="">{t('business.services.selectSetting')}</option>
{settings.map(s => (
<option key={s.id} value={s.id}>
{s.name} ({s.currency}) - {s.markup_factor}x
</option>
))}
</select>
</div>
<div className="col-12">
<label className="form-label text-slate-400">{t('business.services.description')}</label>
<textarea
className="form-control bg-dark text-white border-secondary"
name="description"
value={formData.description}
onChange={handleChange}
rows={2}
placeholder={t('business.services.descriptionPlaceholder')}
/>
</div>
{/* Insumos / CSV */}
<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-box-seam me-2"></i>
{t('business.services.supplies')} (CSV)
</h6>
<button type="button" className="btn btn-sm btn-outline-success" onClick={addItem}>
<i className="bi bi-plus-lg me-1"></i>
{t('business.services.addSupply')}
</button>
</div>
{items.length === 0 ? (
<div className="text-center py-4" style={{ background: '#0f172a', borderRadius: '8px' }}>
<i className="bi bi-inbox text-slate-500 fs-1 mb-2 d-block"></i>
<p className="text-slate-400 small mb-2">{t('business.services.noSupplies')}</p>
<button type="button" className="btn btn-sm btn-success" onClick={addItem}>
<i className="bi bi-plus-lg me-1"></i>
{t('business.services.addFirst')}
</button>
</div>
) : (
<div className="table-responsive">
<table className="table table-dark table-sm mb-0" style={{ background: '#0f172a' }}>
<thead>
<tr className="text-slate-400 small">
<th style={{ width: '35%' }}>{t('business.services.supplyName')}</th>
<th style={{ width: '20%' }}>{t('business.services.type')}</th>
<th style={{ width: '15%' }}>{t('business.services.unitCost')}</th>
<th style={{ width: '15%' }}>{t('business.services.quantity')}</th>
<th style={{ width: '10%' }}>{t('business.services.total')}</th>
<th style={{ width: '5%' }}></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<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.services.supplyPlaceholder')}
/>
</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"
value={item.unit_cost}
onChange={(e) => handleItemChange(index, 'unit_cost', e.target.value)}
placeholder="0.00"
/>
</td>
<td>
<div className="input-group input-group-sm">
<input
type="number"
step="0.01"
min="0.01"
className="form-control bg-dark text-white border-secondary"
value={item.quantity_used}
onChange={(e) => handleItemChange(index, 'quantity_used', e.target.value)}
/>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
value={item.unit}
onChange={(e) => handleItemChange(index, 'unit', e.target.value)}
placeholder={t('business.services.unitPlaceholder')}
style={{ maxWidth: '50px' }}
/>
</div>
</td>
<td className="text-success text-end">
{currency((parseFloat(item.unit_cost) || 0) * (parseFloat(item.quantity_used) || 1), selectedSetting?.currency || 'EUR')}
</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>
<tfoot>
<tr className="border-top border-secondary">
<td colSpan="4" className="text-end text-slate-400 fw-bold">
{t('business.services.totalCsv')}:
</td>
<td className="text-danger fw-bold text-end">
{currency(calculateCsv(), selectedSetting?.currency || 'EUR')}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
{/* Preview de precio */}
{preview && (
<div className="col-12">
<hr className="border-secondary my-2" />
<div className="card bg-success bg-opacity-10 border-success">
<div className="card-body py-3">
<h6 className="text-success mb-3">
<i className="bi bi-calculator me-2"></i>
{t('business.services.pricePreview')}
</h6>
<div className="row g-3 small">
<div className="col-md-3">
<div className="p-2 rounded text-center" style={{ background: 'rgba(59, 130, 246, 0.2)' }}>
<small className="text-slate-400 d-block">{t('business.services.fixedCostPortion')}</small>
<span className="text-info fw-bold">
{currency(preview.fixedCostPortion, preview.currency)}
</span>
<small className="text-slate-500 d-block">
({currency(preview.fixedCostPerHour, preview.currency)}/h × {preview.duration}min)
</small>
</div>
</div>
<div className="col-md-2">
<div className="p-2 rounded text-center" style={{ background: 'rgba(239, 68, 68, 0.2)' }}>
<small className="text-slate-400 d-block">CSV</small>
<span className="text-danger fw-bold">
{currency(preview.csv, preview.currency)}
</span>
</div>
</div>
<div className="col-md-2">
<div className="p-2 rounded text-center" style={{ background: 'rgba(168, 85, 247, 0.2)' }}>
<small className="text-slate-400 d-block">{t('business.services.baseCost')}</small>
<span className="text-purple fw-bold" style={{ color: '#a855f7' }}>
{currency(preview.baseCost, preview.currency)}
</span>
</div>
</div>
<div className="col-md-2">
<div className="p-2 rounded text-center" style={{ background: 'rgba(251, 191, 36, 0.2)' }}>
<small className="text-slate-400 d-block">{t('business.common.timesMarkup')}</small>
<span className="text-warning fw-bold">{preview.markup.toFixed(2)}x</span>
</div>
</div>
<div className="col-md-3">
<div className="p-2 rounded text-center" style={{ background: 'rgba(16, 185, 129, 0.2)' }}>
<small className="text-slate-400 d-block">{t('business.services.finalPrice')}</small>
<span className="text-success fw-bold fs-5">
{currency(preview.finalPrice, preview.currency)}
</span>
{preview.vatRate > 0 && (
<small className="text-slate-500 d-block">
({t('business.services.includesVat')} {preview.vatRate}%)
</small>
)}
</div>
</div>
</div>
<div className="mt-3 text-center">
<small className="text-slate-400">
<i className="bi bi-info-circle me-1"></i>
{t('business.services.formula')}: ({currency(preview.fixedCostPortion, preview.currency)} + {currency(preview.csv, preview.currency)}) × {preview.markup.toFixed(2)}
{preview.vatRate > 0 && ` × ${(1 + preview.vatRate/100).toFixed(2)}`}
{' '}= <strong className="text-success">{currency(preview.finalPrice, preview.currency)}</strong>
</small>
</div>
</div>
</div>
</div>
)}
{/* Strategic Pricing Toggle */}
<div className="col-12">
<button
type="button"
className="btn btn-sm btn-outline-secondary w-100"
onClick={() => setShowStrategicPricing(!showStrategicPricing)}
>
<i className={`bi ${showStrategicPricing ? 'bi-chevron-up' : 'bi-chevron-down'} me-2`}></i>
{t('business.services.strategicPricing')}
</button>
</div>
{/* Strategic Pricing Fields */}
{showStrategicPricing && (
<>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.services.competitorPrice')}</label>
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="competitor_price"
value={formData.competitor_price}
onChange={handleChange}
placeholder="0.00"
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.services.minPrice')}</label>
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="min_price"
value={formData.min_price}
onChange={handleChange}
placeholder="0.00"
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.services.maxPrice')}</label>
<input
type="number"
step="0.01"
className="form-control bg-dark text-white border-secondary"
name="max_price"
value={formData.max_price}
onChange={handleChange}
placeholder="0.00"
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.services.strategy')}</label>
<select
className="form-select bg-dark text-white border-secondary"
name="pricing_strategy"
value={formData.pricing_strategy}
onChange={handleChange}
>
<option value="aggressive">{t('business.services.strategyAggressive')}</option>
<option value="neutral">{t('business.services.strategyNeutral')}</option>
<option value="premium">{t('business.services.strategyPremium')}</option>
</select>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.services.premiumMultiplier')}</label>
<input
type="number"
step="0.01"
min="0.5"
max="3"
className="form-control bg-dark text-white border-secondary"
name="premium_multiplier"
value={formData.premium_multiplier}
onChange={handleChange}
/>
</div>
<div className="col-md-4">
<label className="form-label text-slate-400">{t('business.services.targetMargin')}</label>
<div className="input-group">
<input
type="number"
step="0.1"
className="form-control bg-dark text-white border-secondary"
name="target_margin"
value={formData.target_margin}
onChange={handleChange}
placeholder="30"
/>
<span className="input-group-text bg-dark text-slate-400 border-secondary">%</span>
</div>
</div>
<div className="col-12">
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="psychological_pricing"
name="psychological_pricing"
checked={formData.psychological_pricing}
onChange={handleChange}
/>
<label className="form-check-label text-slate-400" htmlFor="psychological_pricing">
{t('business.services.psychologicalPricing')}
<small className="d-block text-slate-500">{t('business.services.psychologicalPricingHelp')}</small>
</label>
</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.services.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-success" 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 ServiceSheetModal;

View File

@ -0,0 +1,317 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { serviceSheetService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
import ServiceSheetModal from './ServiceSheetModal';
const ServiceSheetsTab = ({ 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' });
// Filtrar configurações que permitem serviços
const serviceSettings = settings.filter(s => s.business_type === 'services' || s.business_type === 'both');
const handleCreate = () => {
if (serviceSettings.length === 0) {
alert(t('business.services.noServiceSettings'));
return;
}
setEditingSheet(null);
setShowModal(true);
};
const handleEdit = (sheet) => {
setEditingSheet(sheet);
setShowModal(true);
};
const handleDuplicate = async (sheet) => {
try {
const duplicated = await serviceSheetService.duplicate(sheet.id);
onCreated(duplicated);
} catch (err) {
alert(err.response?.data?.message || t('common.error'));
}
};
const handleDelete = async (sheet) => {
if (!window.confirm(t('business.services.confirmDelete'))) return;
setDeleting(sheet.id);
try {
await serviceSheetService.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))];
// Formatar duração
const formatDuration = (minutes) => {
if (minutes < 60) return `${minutes} ${t('business.common.minuteShort')}`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}${t('business.common.hourShort')} ${mins}${t('business.common.minuteShort')}` : `${hours}${t('business.common.hourShort')}`;
};
return (
<>
{/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 className="text-white mb-1">{t('business.services.title')}</h5>
<p className="text-slate-400 small mb-0">{t('business.services.description')}</p>
</div>
<button className="btn btn-success" onClick={handleCreate}>
<i className="bi bi-plus-lg me-2"></i>
{t('business.services.add')}
</button>
</div>
{/* Alerta se no hay configuraciones de servicio */}
{serviceSettings.length === 0 && (
<div className="alert alert-warning d-flex align-items-center mb-4">
<i className="bi bi-exclamation-triangle me-2"></i>
<div>
<strong>{t('business.services.noServiceSettings')}</strong>
<p className="mb-0 small">{t('business.services.createServiceSetting')}</p>
</div>
</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.services.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 && serviceSettings.length > 0 ? (
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-5">
<i className="bi bi-person-workspace fs-1 text-slate-500 mb-3 d-block"></i>
<h5 className="text-white mb-2">{t('business.services.empty')}</h5>
<p className="text-slate-400 mb-4">{t('business.services.emptyDescription')}</p>
<button className="btn btn-success" onClick={handleCreate}>
<i className="bi bi-plus-lg me-2"></i>
{t('business.services.createFirst')}
</button>
</div>
</div>
) : filteredSheets.length === 0 && sheets.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.services.noResults')}</p>
</div>
) : sheets.length > 0 && (
/* Services 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">
<i className="bi bi-person-workspace me-2 text-success"></i>
{sheet.name}
{!sheet.is_active && (
<span className="badge bg-secondary ms-2">{t('common.inactive')}</span>
)}
</h6>
<div className="d-flex gap-2 align-items-center">
{sheet.code && <small className="text-slate-500">{t('business.common.codeLabel')}: {sheet.code}</small>}
<span className="badge bg-success bg-opacity-25 text-success">
<i className="bi bi-clock me-1"></i>
{formatDuration(sheet.duration_minutes)}
</span>
{sheet.category && (
<span className="badge bg-secondary" style={{ fontSize: '10px' }}>{sheet.category}</span>
)}
</div>
</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.services.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">
{/* Breakdown de custos */}
<div className="row g-2 mb-3">
<div className="col-4">
<div className="p-2 rounded text-center" style={{ background: 'rgba(59, 130, 246, 0.1)' }}>
<small className="text-slate-500 d-block">{t('business.services.fixedCost')}</small>
<span className="text-info fw-bold small">
{currency(sheet.fixed_cost_portion, sheet.business_setting?.currency || 'EUR')}
</span>
</div>
</div>
<div className="col-4">
<div className="p-2 rounded text-center" style={{ background: 'rgba(239, 68, 68, 0.1)' }}>
<small className="text-slate-500 d-block">{t('business.common.csvLabel')}</small>
<span className="text-danger fw-bold small">
{currency(sheet.total_csv, sheet.business_setting?.currency || 'EUR')}
</span>
</div>
</div>
<div className="col-4">
<div className="p-2 rounded text-center" style={{ background: 'rgba(16, 185, 129, 0.1)' }}>
<small className="text-slate-500 d-block">{t('business.services.price')}</small>
<span className="text-success fw-bold">
{currency(sheet.calculated_price, sheet.business_setting?.currency || 'EUR')}
</span>
</div>
</div>
</div>
{/* Margem */}
<div className="d-flex justify-content-between align-items-center mb-2">
<span className="text-slate-400 small">{t('business.services.margin')}</span>
<span className={`fw-bold ${parseFloat(sheet.margin_percentage) >= 20 ? 'text-success' : parseFloat(sheet.margin_percentage) >= 10 ? 'text-warning' : 'text-danger'}`}>
{parseFloat(sheet.margin_percentage || 0).toFixed(1)}%
</span>
</div>
{/* Progress bar de margem */}
<div className="progress" style={{ height: '4px', background: '#374151' }}>
<div
className={`progress-bar ${parseFloat(sheet.margin_percentage) >= 20 ? 'bg-success' : parseFloat(sheet.margin_percentage) >= 10 ? 'bg-warning' : 'bg-danger'}`}
style={{ width: `${Math.min(parseFloat(sheet.margin_percentage) || 0, 100)}%` }}
></div>
</div>
{/* Componentes do serviço */}
{sheet.items && sheet.items.length > 0 && (
<div className="mt-3">
<small className="text-slate-500">{t('business.services.supplies')} ({sheet.items.length})</small>
<div className="d-flex flex-wrap gap-1 mt-1">
{sheet.items.slice(0, 3).map(item => (
<span key={item.id} className="badge bg-dark text-slate-400" style={{ fontSize: '10px' }}>
{item.name}
</span>
))}
{sheet.items.length > 3 && (
<span className="badge bg-dark text-slate-400" style={{ fontSize: '10px' }}>
{t('business.common.moreItems', { count: sheet.items.length - 3 })}
</span>
)}
</div>
</div>
)}
</div>
<div className="card-footer border-0 py-2" style={{ background: 'transparent' }}>
<div className="d-flex justify-content-between align-items-center">
<small className="text-slate-500">
{sheet.business_setting?.name}
</small>
<button
className="btn btn-sm btn-outline-success"
onClick={() => handleEdit(sheet)}
>
{t('common.edit')}
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{showModal && (
<ServiceSheetModal
sheet={editingSheet}
settings={serviceSettings}
onSave={handleSave}
onClose={() => setShowModal(false)}
/>
)}
</>
);
};
export default ServiceSheetsTab;

View File

@ -1078,10 +1078,26 @@
},
"business": {
"title": "Business",
"subtitle": "Manage pricing settings and product technical sheets",
"subtitle": "Manage pricing settings and technical sheets for products and services",
"common": {
"cmvLabel": "COGS",
"csvLabel": "COSS",
"markupLabel": "Markup",
"timesMarkup": "× Markup",
"skuLabel": "SKU",
"codeLabel": "CODE",
"minuteShort": "min",
"hourShort": "h",
"hoursPerDayUnit": "h/day",
"perHourUnit": "/h",
"plusVat": "(1 + VAT)",
"moreItems": "+{{count}}"
},
"tabs": {
"settings": "Settings",
"products": "Products",
"services": "Services",
"campaigns": "Campaigns",
"calculator": "Calculator"
},
"settings": {
@ -1107,11 +1123,57 @@
"markupFactor": "Markup Factor",
"totalDeductions": "Total Deductions",
"markupPreview": "Markup Preview",
"priceType": "Price Type",
"b2cDescription": "PVP includes VAT (retail to consumer)",
"b2bDescription": "Prices without VAT (business to business)",
"vatRate": "VAT Rate",
"vatRateHelp": "VAT percentage to add to final price",
"vatRateExample": "E.g.: 21% in Spain, 23% in Portugal",
"otherTaxes": "Other Taxes (%)",
"otherTaxesHelp": "Other taxes besides VAT (e.g.: special fees)",
"finalMultiplier": "Final Multiplier (with VAT)",
"baseMarkup": "Base Markup (without VAT)",
"vatIncluded": "VAT included in final price",
"monthlyRevenueHelp": "Expected average monthly revenue",
"fixedExpensesHelp": "Rent, salaries, fixed bills, etc.",
"investmentRateHelp": "Percentage to reinvest in the business",
"profitMarginHelp": "Desired net profit",
"revenueAndExpenses": "Revenue and Expenses",
"investmentAndProfit": "Investment and Profit",
"invalidMarkup": "Invalid markup - deductions exceed 100%",
"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%"
"empty": "No settings",
"emptyDescription": "Create your first markup setting to get started",
"errorTotalExceeds": "Total deductions cannot exceed 100%",
"businessType": "Business Type",
"typeProducts": "Products",
"typeServices": "Services",
"typeBoth": "Both",
"typeProductsHelp": "Sells physical products",
"typeServicesHelp": "Sells services",
"typeBothHelp": "Sells products and services",
"productiveHours": "Productive Hours",
"productiveHoursConfig": "Productive Hours Configuration",
"productiveHoursHelp": "Configure to calculate the fixed cost per service hour",
"employeesCount": "Employees",
"employeesCountHelp": "Number of employees providing services",
"hoursPerDay": "Hours/Day",
"hoursPerDayHelp": "Work hours per day",
"hoursPerWeek": "Hours/Week",
"hoursPerWeekHelp": "Total work hours per week",
"daysPerWeek": "Days/Week",
"daysPerWeekHelp": "Working days per week",
"derivedHoursPerDay": "Hours/Day (calculated)",
"workingDays": "Days/Month",
"workingDaysHelp": "Working days per month",
"productivity": "Productivity",
"productivityHelp": "Percentage of actually productive time",
"calculatedProductiveHours": "Monthly Productive Hours",
"fixedCostPerHour": "Fixed Cost/Hour",
"totalProductiveHours": "Monthly Productive Hours"
},
"products": {
"title": "Product Technical Sheets",
@ -1126,12 +1188,18 @@
"currency": "Currency",
"businessSetting": "Business Setting",
"selectSetting": "Select setting",
"noSetting": "No setting",
"isActive": "Active",
"cmvTotal": "Total COGS",
"salePrice": "Sale Price",
"finalPrice": "Final Price",
"contributionMargin": "Contribution Margin",
"realMargin": "Real Margin",
"noProducts": "No products registered",
"createFirst": "Create your first technical sheet to calculate prices",
"empty": "No products",
"emptyDescription": "Create your first product technical sheet to get started",
"noResults": "No products found with selected filters",
"confirmDelete": "Are you sure you want to delete this product?",
"duplicate": "Duplicate",
"duplicateSuccess": "Product duplicated successfully",
@ -1139,7 +1207,119 @@
"filterCategory": "Filter by category",
"filterStatus": "Filter by status",
"allCategories": "All categories",
"allStatus": "All statuses"
"allStatus": "All statuses",
"strategicPricing": "Strategic Pricing",
"competitorPrice": "Competitor Price",
"minPrice": "Minimum Price",
"maxPrice": "Maximum Price",
"premiumMultiplier": "Premium Multiplier",
"neutral": "Neutral",
"priceStrategy": "Price Strategy",
"strategyAggressive": "Aggressive (-5% competitor)",
"strategyNeutral": "Neutral (calculated price)",
"strategyPremium": "Premium (+10% competitor)",
"targetMargin": "Target Margin (%)",
"useGlobal": "Use global",
"psychologicalRounding": "Psychological Rounding",
"psychologicalExample": "e.g.: 25.99 instead of 26.04",
"costComponents": "Cost Components (COGS)",
"addComponent": "Add Component",
"noComponents": "No cost components",
"competitorComparison": "vs Competitor",
"belowCompetitor": "below",
"aboveCompetitor": "above",
"componentName": "Component Name",
"componentNamePlaceholder": "E.g.: Main fabric",
"amount": "Amount",
"quantity": "Quantity",
"unitCost": "Unit Cost",
"selectSettingForPrice": "Select a setting to view the price",
"itemTypes": {
"productCost": "Product Cost",
"packaging": "Packaging",
"label": "Label",
"shipping": "Shipping",
"handling": "Handling",
"other": "Other"
},
"skuPlaceholder": "ABC-001",
"cmvTotalLabel": "Total COGS",
"strategyAggressiveLabel": "Aggressive",
"strategyPremiumLabel": "Premium",
"psychologicalBadge": ".99",
"components": "Components"
},
"services": {
"title": "Service Technical Sheets",
"description": "Manage the COSS (Cost of Service Sold) for each service",
"add": "New Service",
"edit": "Edit Service",
"name": "Service Name",
"namePlaceholder": "E.g.: Haircut",
"code": "Code",
"category": "Category",
"categoryPlaceholder": "E.g.: Cuts",
"duration": "Duration",
"description": "Description",
"descriptionPlaceholder": "Describe the service...",
"businessSetting": "Business Setting",
"selectSetting": "Select setting",
"isActive": "Active",
"supplies": "Supplies / Materials",
"addSupply": "Add Supply",
"noSupplies": "No supplies registered",
"addFirst": "Add the service supply costs",
"supplyName": "Supply Name",
"supplyPlaceholder": "E.g.: Shampoo",
"type": "Type",
"unitCost": "Unit Cost",
"quantity": "Quantity",
"total": "Total",
"totalCsv": "Total COSS",
"pricePreview": "Price Preview",
"fixedCostPortion": "Fixed Cost Portion",
"baseCost": "Base Cost",
"finalPrice": "Final Price",
"includesVat": "Includes VAT",
"formula": "Formula",
"strategicPricing": "Strategic Pricing",
"competitorPrice": "Competitor Price",
"minPrice": "Minimum Price",
"maxPrice": "Maximum Price",
"strategy": "Strategy",
"strategyAggressive": "Aggressive",
"strategyNeutral": "Neutral",
"strategyPremium": "Premium",
"premiumMultiplier": "Premium Multiplier",
"targetMargin": "Target Margin",
"psychologicalPricing": "Psychological Pricing",
"psychologicalPricingHelp": "Round to .99 endings",
"noServices": "No services registered",
"createFirst": "Create your first service technical sheet",
"empty": "No services",
"emptyDescription": "Create your first service technical sheet to get started",
"noResults": "No services found with selected filters",
"confirmDelete": "Are you sure you want to delete this service?",
"noServiceSettings": "No service configurations found",
"createServiceSetting": "Create a setting with type 'Services' or 'Both' to get started",
"duplicate": "Duplicate",
"duplicateSuccess": "Service duplicated successfully",
"filterCategory": "Filter by category",
"filterStatus": "Filter by status",
"allCategories": "All categories",
"allStatus": "All statuses",
"margin": "Margin",
"codePlaceholder": "SRV-001",
"unitPlaceholder": "ml",
"fixedCost": "Fixed Cost",
"price": "Price",
"itemTypes": {
"supply": "Supply",
"consumable": "Consumable",
"material": "Material",
"equipmentUsage": "Equipment Usage",
"other": "Other"
}
},
"items": {
"title": "Cost Components",
@ -1179,5 +1359,104 @@
"noSettings": "No Settings",
"createSettingFirst": "First create a markup setting in the Settings tab"
}
},
"campaigns": {
"title": "Promotional Campaigns",
"subtitle": "Manage offers, discounts and sales events",
"new": "New Campaign",
"create": "Create Campaign",
"edit": "Edit Campaign",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete the campaign \"{{name}}\"?",
"empty": "No promotional campaigns",
"createFirst": "Create your first campaign",
"quickStart": "Quick Start - Presets",
"discount": "Discount",
"period": "Period",
"products": "Products",
"productsSelected": "products selected",
"allProducts": "All",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"daysRemaining": "Days Remaining",
"days": "days",
"status": {
"active": "Active",
"scheduled": "Scheduled",
"ended": "Ended",
"inactive": "Inactive"
},
"filter": {
"all": "All",
"active": "Active",
"scheduled": "Scheduled",
"ended": "Ended",
"inactive": "Inactive"
},
"discountTypes": {
"percentage": "Percentage (%)",
"fixed": "Fixed Value",
"priceOverride": "Fixed Price"
},
"steps": {
"details": "Details",
"products": "Products",
"review": "Review"
},
"form": {
"name": "Campaign Name",
"namePlaceholder": "E.g.: Black Friday 2024",
"code": "Code",
"description": "Description",
"discountType": "Discount Type",
"discountValue": "Discount Value",
"example": "Example",
"minPrice": "Minimum Price",
"minPricePlaceholder": "No limit",
"minPriceHelp": "Promotional price will not go below this value",
"profitProtection": "Profit Protection",
"protectAgainstLoss": "Never sell at a loss",
"protectAgainstLossHelp": "The system will automatically adjust prices so they never go below cost (COGS)",
"minMarginPercent": "Minimum Margin (%)",
"minMarginHelp": "Required minimum margin over cost",
"startDate": "Start Date",
"endDate": "End Date",
"startTime": "Start Time",
"endTime": "End Time",
"isActive": "Campaign Active",
"applyToAll": "Apply to All Products",
"applyToAllHelp": "Discount will be automatically applied to all active products",
"badgeSettings": "Badge Settings",
"showBadge": "Show Discount Badge",
"showOriginalPrice": "Show Original Price Crossed Out",
"badgeText": "Badge Text",
"badgeColor": "Badge Color",
"preview": "Preview"
},
"review": {
"summary": "Campaign Summary",
"priceImpact": "Price Impact",
"totalOriginal": "Total Original",
"totalPromo": "Total Promotional",
"avgDiscount": "Average Discount",
"product": "Product",
"original": "Original",
"promo": "Promo",
"savings": "Savings",
"andMore": "and {{count}} more...",
"profitWarning": "Warning! Products with loss",
"unprofitableProducts": "{{count}} product(s) would have negative margin without protection",
"pricesProtected": "Prices protected",
"protectedProducts": "{{count}} product(s) had their price adjusted to maintain profitability",
"profitAnalysis": "Profitability Analysis",
"totalProfit": "Total Profit",
"avgPromoMargin": "Average Margin",
"originalMargin": "Original Margin",
"productDetails": "Product Details",
"cmv": "COGS",
"margin": "Margin",
"status": "Status",
"totalCmv": "Total COGS"
}
}
}

View File

@ -1078,10 +1078,26 @@
},
"business": {
"title": "Negocio",
"subtitle": "Gestiona configuraciones de precios y fichas técnicas de productos",
"subtitle": "Gestiona configuraciones de precios y fichas técnicas de productos y servicios",
"common": {
"cmvLabel": "CMV",
"csvLabel": "CSV",
"markupLabel": "Markup",
"timesMarkup": "× Markup",
"skuLabel": "SKU",
"codeLabel": "CÓD",
"minuteShort": "min",
"hourShort": "h",
"hoursPerDayUnit": "h/día",
"perHourUnit": "/h",
"plusVat": "(1 + IVA)",
"moreItems": "+{{count}}"
},
"tabs": {
"settings": "Configuraciones",
"products": "Productos",
"services": "Servicios",
"campaigns": "Campañas",
"calculator": "Calculadora"
},
"settings": {
@ -1107,11 +1123,57 @@
"markupFactor": "Factor de Markup",
"totalDeductions": "Total de Deducciones",
"markupPreview": "Vista Previa del Markup",
"priceType": "Tipo de Precio",
"b2cDescription": "PVP incluye IVA (venta al consumidor)",
"b2bDescription": "Precios sin IVA (venta entre empresas)",
"vatRate": "Tasa de IVA/VAT",
"vatRateHelp": "Porcentaje de IVA a agregar al precio final",
"vatRateExample": "Ej: 21% en España, 23% en Portugal",
"otherTaxes": "Otros Impuestos (%)",
"otherTaxesHelp": "Otros impuestos además del IVA (ej: tasas especiales)",
"finalMultiplier": "Multiplicador Final (con IVA)",
"baseMarkup": "Markup Base (sin IVA)",
"vatIncluded": "IVA incluido en el precio final",
"monthlyRevenueHelp": "Facturación mensual promedio esperada",
"fixedExpensesHelp": "Alquiler, salarios, gastos fijos, etc.",
"investmentRateHelp": "Porcentaje para reinvertir en el negocio",
"profitMarginHelp": "Ganancia neta deseada",
"revenueAndExpenses": "Ingresos y Gastos",
"investmentAndProfit": "Inversión y Ganancia",
"invalidMarkup": "Markup inválido - deducciones superan 100%",
"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%"
"empty": "Sin configuraciones",
"emptyDescription": "Crea tu primera configuración de markup para comenzar",
"errorTotalExceeds": "El total de deducciones no puede superar 100%",
"businessType": "Tipo de Negocio",
"typeProducts": "Productos",
"typeServices": "Servicios",
"typeBoth": "Ambos",
"typeProductsHelp": "Vende productos físicos",
"typeServicesHelp": "Vende servicios",
"typeBothHelp": "Vende productos y servicios",
"productiveHours": "Horas Productivas",
"productiveHoursConfig": "Configuración de Horas Productivas",
"productiveHoursHelp": "Configura para calcular el costo fijo por hora de servicio",
"employeesCount": "Empleados",
"employeesCountHelp": "Número de empleados que prestan servicios",
"hoursPerDay": "Horas/Día",
"hoursPerDayHelp": "Horas de trabajo por día",
"hoursPerWeek": "Horas/Semana",
"hoursPerWeekHelp": "Total de horas de trabajo por semana",
"daysPerWeek": "Días/Semana",
"daysPerWeekHelp": "Días de trabajo por semana",
"derivedHoursPerDay": "Horas/Día (calculado)",
"workingDays": "Días/Mes",
"workingDaysHelp": "Días de trabajo por mes",
"productivity": "Productividad",
"productivityHelp": "Porcentaje de tiempo realmente productivo",
"calculatedProductiveHours": "Horas Productivas Mensuales",
"fixedCostPerHour": "Costo Fijo/Hora",
"totalProductiveHours": "Horas Productivas Mensuales"
},
"products": {
"title": "Fichas Técnicas de Productos",
@ -1126,12 +1188,18 @@
"currency": "Moneda",
"businessSetting": "Configuración de Negocio",
"selectSetting": "Seleccionar configuración",
"noSetting": "Sin configuración",
"isActive": "Activo",
"cmvTotal": "CMV Total",
"salePrice": "Precio de Venta",
"finalPrice": "Precio Final",
"contributionMargin": "Margen de Contribución",
"realMargin": "Margen Real",
"noProducts": "No hay productos registrados",
"createFirst": "Crea tu primera ficha técnica para calcular precios",
"empty": "Sin productos",
"emptyDescription": "Crea tu primera ficha técnica de producto para comenzar",
"noResults": "No se encontraron productos con los filtros seleccionados",
"confirmDelete": "¿Seguro que deseas eliminar este producto?",
"duplicate": "Duplicar",
"duplicateSuccess": "Producto duplicado correctamente",
@ -1139,7 +1207,119 @@
"filterCategory": "Filtrar por categoría",
"filterStatus": "Filtrar por estado",
"allCategories": "Todas las categorías",
"allStatus": "Todos los estados"
"allStatus": "Todos los estados",
"strategicPricing": "Precificación Estratégica",
"competitorPrice": "Precio Competidor",
"minPrice": "Precio Mínimo",
"maxPrice": "Precio Máximo",
"premiumMultiplier": "Multiplicador Premium",
"neutral": "Neutro",
"priceStrategy": "Estrategia de Precio",
"strategyAggressive": "Agresivo (-5% competidor)",
"strategyNeutral": "Neutro (precio calculado)",
"strategyPremium": "Premium (+10% competidor)",
"targetMargin": "Margen Objetivo (%)",
"useGlobal": "Usar global",
"psychologicalRounding": "Arredondeo Psicológico",
"psychologicalExample": "ej: 25.99 en vez de 26.04",
"costComponents": "Componentes de Costo (CMV)",
"addComponent": "Agregar Componente",
"noComponents": "Sin componentes de costo",
"competitorComparison": "vs Competidor",
"belowCompetitor": "por debajo",
"aboveCompetitor": "por encima",
"componentName": "Nombre del Componente",
"componentNamePlaceholder": "Ej: Tela principal",
"amount": "Valor",
"quantity": "Cantidad",
"unitCost": "Costo Unitario",
"selectSettingForPrice": "Selecciona una configuración para ver el precio",
"itemTypes": {
"productCost": "Costo del Producto",
"packaging": "Embalaje",
"label": "Etiqueta",
"shipping": "Envío",
"handling": "Manipulación",
"other": "Otros"
},
"skuPlaceholder": "ABC-001",
"cmvTotalLabel": "CMV Total",
"strategyAggressiveLabel": "Agresivo",
"strategyPremiumLabel": "Premium",
"psychologicalBadge": ".99",
"components": "Componentes"
},
"services": {
"title": "Fichas Técnicas de Servicios",
"description": "Administra el CSV (Costo del Servicio Vendido) de cada servicio",
"add": "Nuevo Servicio",
"edit": "Editar Servicio",
"name": "Nombre del Servicio",
"namePlaceholder": "Ej: Corte de Pelo",
"code": "Código",
"category": "Categoría",
"categoryPlaceholder": "Ej: Cortes",
"duration": "Duración",
"description": "Descripción",
"descriptionPlaceholder": "Describe el servicio...",
"businessSetting": "Configuración de Negocio",
"selectSetting": "Seleccionar configuración",
"isActive": "Activo",
"supplies": "Insumos / Materiales",
"addSupply": "Agregar Insumo",
"noSupplies": "Sin insumos registrados",
"addFirst": "Agrega los costos de insumos del servicio",
"supplyName": "Nombre del Insumo",
"supplyPlaceholder": "Ej: Champú",
"type": "Tipo",
"unitCost": "Costo Unit.",
"quantity": "Cantidad",
"total": "Total",
"totalCsv": "Total CSV",
"pricePreview": "Vista Previa del Precio",
"fixedCostPortion": "Porción Gasto Fijo",
"baseCost": "Costo Base",
"finalPrice": "Precio Final",
"includesVat": "Incluye IVA",
"formula": "Fórmula",
"strategicPricing": "Precificación Estratégica",
"competitorPrice": "Precio Competidor",
"minPrice": "Precio Mínimo",
"maxPrice": "Precio Máximo",
"strategy": "Estrategia",
"strategyAggressive": "Agresivo",
"strategyNeutral": "Neutro",
"strategyPremium": "Premium",
"premiumMultiplier": "Multiplicador Premium",
"targetMargin": "Margen Objetivo",
"psychologicalPricing": "Precio Psicológico",
"psychologicalPricingHelp": "Redondear a terminaciones en .99",
"noServices": "No hay servicios registrados",
"createFirst": "Crea tu primera ficha técnica de servicio",
"empty": "Sin servicios",
"emptyDescription": "Crea tu primera ficha técnica de servicio para comenzar",
"noResults": "No se encontraron servicios con los filtros seleccionados",
"confirmDelete": "¿Seguro que deseas eliminar este servicio?",
"noServiceSettings": "No hay configuraciones para servicios",
"createServiceSetting": "Crea una configuración con tipo 'Servicios' o 'Ambos' para comenzar",
"duplicate": "Duplicar",
"duplicateSuccess": "Servicio duplicado correctamente",
"filterCategory": "Filtrar por categoría",
"filterStatus": "Filtrar por estado",
"allCategories": "Todas las categorías",
"allStatus": "Todos los estados",
"margin": "Margen",
"codePlaceholder": "SRV-001",
"unitPlaceholder": "ml",
"fixedCost": "Costo Fijo",
"price": "Precio",
"itemTypes": {
"supply": "Insumo",
"consumable": "Consumible",
"material": "Material",
"equipmentUsage": "Uso de Equipo",
"other": "Otros"
}
},
"items": {
"title": "Componentes de Costo",
@ -1179,5 +1359,104 @@
"noSettings": "Sin Configuraciones",
"createSettingFirst": "Primero crea una configuración de markup en la pestaña Configuraciones"
}
},
"campaigns": {
"title": "Campañas Promocionales",
"subtitle": "Gestiona ofertas, descuentos y eventos de ventas",
"new": "Nueva Campaña",
"create": "Crear Campaña",
"edit": "Editar Campaña",
"delete": "Eliminar",
"deleteConfirm": "¿Seguro que deseas eliminar la campaña \"{{name}}\"?",
"empty": "No hay campañas promocionales",
"createFirst": "Crea tu primera campaña",
"quickStart": "Inicio Rápido - Presets",
"discount": "Descuento",
"period": "Período",
"products": "Productos",
"productsSelected": "productos seleccionados",
"allProducts": "Todos",
"selectAll": "Seleccionar Todos",
"deselectAll": "Deseleccionar Todos",
"daysRemaining": "Días Restantes",
"days": "días",
"status": {
"active": "Activa",
"scheduled": "Programada",
"ended": "Finalizada",
"inactive": "Inactiva"
},
"filter": {
"all": "Todas",
"active": "Activas",
"scheduled": "Programadas",
"ended": "Finalizadas",
"inactive": "Inactivas"
},
"discountTypes": {
"percentage": "Porcentaje (%)",
"fixed": "Valor Fijo",
"priceOverride": "Precio Fijo"
},
"steps": {
"details": "Detalles",
"products": "Productos",
"review": "Revisar"
},
"form": {
"name": "Nombre de la Campaña",
"namePlaceholder": "Ej: Black Friday 2024",
"code": "Código",
"description": "Descripción",
"discountType": "Tipo de Descuento",
"discountValue": "Valor del Descuento",
"example": "Ejemplo",
"minPrice": "Precio Mínimo",
"minPricePlaceholder": "Sin límite",
"minPriceHelp": "El precio promocional no bajará de este valor",
"profitProtection": "Protección de Rentabilidad",
"protectAgainstLoss": "Nunca vender con pérdida",
"protectAgainstLossHelp": "El sistema ajustará automáticamente el precio para que nunca sea menor que el costo (CMV)",
"minMarginPercent": "Margen Mínimo (%)",
"minMarginHelp": "Margen mínimo obligatorio sobre el costo",
"startDate": "Fecha Inicio",
"endDate": "Fecha Fin",
"startTime": "Hora Inicio",
"endTime": "Hora Fin",
"isActive": "Campaña Activa",
"applyToAll": "Aplicar a Todos los Productos",
"applyToAllHelp": "El descuento se aplicará automáticamente a todos los productos activos",
"badgeSettings": "Configuración de Badge",
"showBadge": "Mostrar Badge de Descuento",
"showOriginalPrice": "Mostrar Precio Original Tachado",
"badgeText": "Texto del Badge",
"badgeColor": "Color del Badge",
"preview": "Vista Previa"
},
"review": {
"summary": "Resumen de la Campaña",
"priceImpact": "Impacto en Precios",
"totalOriginal": "Total Original",
"totalPromo": "Total Promocional",
"avgDiscount": "Descuento Promedio",
"product": "Producto",
"original": "Original",
"promo": "Promo",
"savings": "Ahorro",
"andMore": "y {{count}} más...",
"profitWarning": "¡Atención! Productos con pérdida",
"unprofitableProducts": "{{count}} producto(s) tendrían margen negativo sin la protección",
"pricesProtected": "Precios protegidos",
"protectedProducts": "{{count}} producto(s) tuvieron el precio ajustado para mantener rentabilidad",
"profitAnalysis": "Análisis de Rentabilidad",
"totalProfit": "Lucro Total",
"avgPromoMargin": "Margen Promedio",
"originalMargin": "Margen Original",
"productDetails": "Detalle por Producto",
"cmv": "CMV",
"margin": "Margen",
"status": "Estado",
"totalCmv": "CMV Total"
}
}
}

View File

@ -1080,10 +1080,26 @@
},
"business": {
"title": "Negócios",
"subtitle": "Gerencie configurações de preços e fichas técnicas de produtos",
"subtitle": "Gerencie configurações de preços e fichas técnicas de produtos e serviços",
"common": {
"cmvLabel": "CMV",
"csvLabel": "CSV",
"markupLabel": "Markup",
"timesMarkup": "× Markup",
"skuLabel": "SKU",
"codeLabel": "CÓD",
"minuteShort": "min",
"hourShort": "h",
"hoursPerDayUnit": "h/dia",
"perHourUnit": "/h",
"plusVat": "(1 + IVA)",
"moreItems": "+{{count}}"
},
"tabs": {
"settings": "Configurações",
"products": "Produtos",
"services": "Serviços",
"campaigns": "Campanhas",
"calculator": "Calculadora"
},
"settings": {
@ -1109,11 +1125,57 @@
"markupFactor": "Fator de Markup",
"totalDeductions": "Total de Deduções",
"markupPreview": "Pré-visualização do Markup",
"priceType": "Tipo de Preço",
"b2cDescription": "PVP inclui IVA (venda ao consumidor)",
"b2bDescription": "Preços sem IVA (venda entre empresas)",
"vatRate": "Taxa de IVA/VAT",
"vatRateHelp": "Percentual de IVA a adicionar ao preço final",
"vatRateExample": "Ex: 21% na Espanha, 23% em Portugal",
"otherTaxes": "Outros Impostos (%)",
"otherTaxesHelp": "Outros impostos além do IVA (ex: taxas especiais)",
"finalMultiplier": "Multiplicador Final (com IVA)",
"baseMarkup": "Markup Base (sem IVA)",
"vatIncluded": "IVA incluído no preço final",
"monthlyRevenueHelp": "Faturamento médio mensal esperado",
"fixedExpensesHelp": "Aluguel, salários, contas fixas, etc.",
"investmentRateHelp": "Percentual para reinvestir no negócio",
"profitMarginHelp": "Lucro líquido desejado",
"revenueAndExpenses": "Receita e Despesas",
"investmentAndProfit": "Investimento e Lucro",
"invalidMarkup": "Markup inválido - deduções excedem 100%",
"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%"
"empty": "Sem configurações",
"emptyDescription": "Crie sua primeira configuração de markup para começar",
"errorTotalExceeds": "O total de deduções não pode exceder 100%",
"businessType": "Tipo de Negócio",
"typeProducts": "Produtos",
"typeServices": "Serviços",
"typeBoth": "Ambos",
"typeProductsHelp": "Vende produtos físicos",
"typeServicesHelp": "Vende serviços",
"typeBothHelp": "Vende produtos e serviços",
"productiveHours": "Horas Produtivas",
"productiveHoursConfig": "Configuração de Horas Produtivas",
"productiveHoursHelp": "Configure para calcular o custo fixo por hora de serviço",
"employeesCount": "Funcionários",
"employeesCountHelp": "Número de funcionários que prestam serviços",
"hoursPerDay": "Horas/Dia",
"hoursPerDayHelp": "Horas de trabalho por dia",
"hoursPerWeek": "Horas/Semana",
"hoursPerWeekHelp": "Total de horas de trabalho por semana",
"daysPerWeek": "Dias/Semana",
"daysPerWeekHelp": "Dias de trabalho por semana",
"derivedHoursPerDay": "Horas/Dia (calculado)",
"workingDays": "Dias/Mês",
"workingDaysHelp": "Dias de trabalho por mês",
"productivity": "Produtividade",
"productivityHelp": "Percentual de tempo realmente produtivo",
"calculatedProductiveHours": "Horas Produtivas Mensais",
"fixedCostPerHour": "Custo Fixo/Hora",
"totalProductiveHours": "Horas Produtivas Mensais"
},
"products": {
"title": "Fichas Técnicas de Produtos",
@ -1128,12 +1190,18 @@
"currency": "Moeda",
"businessSetting": "Configuração de Negócio",
"selectSetting": "Selecionar configuração",
"noSetting": "Sem configuração",
"isActive": "Ativo",
"cmvTotal": "CMV Total",
"salePrice": "Preço de Venda",
"finalPrice": "Preço Final",
"contributionMargin": "Margem de Contribuição",
"realMargin": "Margem Real",
"noProducts": "Nenhum produto cadastrado",
"createFirst": "Crie sua primeira ficha técnica para calcular preços",
"empty": "Sem produtos",
"emptyDescription": "Crie sua primeira ficha técnica de produto para começar",
"noResults": "Nenhum produto encontrado com os filtros selecionados",
"confirmDelete": "Tem certeza que deseja excluir este produto?",
"duplicate": "Duplicar",
"duplicateSuccess": "Produto duplicado com sucesso",
@ -1141,7 +1209,119 @@
"filterCategory": "Filtrar por categoria",
"filterStatus": "Filtrar por status",
"allCategories": "Todas as categorias",
"allStatus": "Todos os status"
"allStatus": "Todos os status",
"strategicPricing": "Precificação Estratégica",
"competitorPrice": "Preço Concorrente",
"minPrice": "Preço Mínimo",
"maxPrice": "Preço Máximo",
"premiumMultiplier": "Multiplicador Premium",
"neutral": "Neutro",
"priceStrategy": "Estratégia de Preço",
"strategyAggressive": "Agressivo (-5% concorrente)",
"strategyNeutral": "Neutro (preço calculado)",
"strategyPremium": "Premium (+10% concorrente)",
"targetMargin": "Margem Alvo (%)",
"useGlobal": "Usar global",
"psychologicalRounding": "Arredondamento Psicológico",
"psychologicalExample": "ex: 25,99 em vez de 26,04",
"costComponents": "Componentes de Custo (CMV)",
"addComponent": "Adicionar Componente",
"noComponents": "Sem componentes de custo",
"competitorComparison": "vs Concorrente",
"belowCompetitor": "abaixo",
"aboveCompetitor": "acima",
"componentName": "Nome do Componente",
"componentNamePlaceholder": "Ex: Tecido principal",
"amount": "Valor",
"quantity": "Quantidade",
"unitCost": "Custo Unitário",
"selectSettingForPrice": "Selecione uma configuração para ver o preço",
"itemTypes": {
"productCost": "Custo do Produto",
"packaging": "Embalagem",
"label": "Etiqueta",
"shipping": "Frete",
"handling": "Manuseio",
"other": "Outros"
},
"skuPlaceholder": "ABC-001",
"cmvTotalLabel": "CMV Total",
"strategyAggressiveLabel": "Agressivo",
"strategyPremiumLabel": "Premium",
"psychologicalBadge": ".99",
"components": "Componentes"
},
"services": {
"title": "Fichas Técnicas de Serviços",
"description": "Gerencie o CSV (Custo do Serviço Vendido) de cada serviço",
"add": "Novo Serviço",
"edit": "Editar Serviço",
"name": "Nome do Serviço",
"namePlaceholder": "Ex: Corte de Cabelo",
"code": "Código",
"category": "Categoria",
"categoryPlaceholder": "Ex: Cortes",
"duration": "Duração",
"description": "Descrição",
"descriptionPlaceholder": "Descreva o serviço...",
"businessSetting": "Configuração de Negócio",
"selectSetting": "Selecionar configuração",
"isActive": "Ativo",
"supplies": "Insumos / Materiais",
"addSupply": "Adicionar Insumo",
"noSupplies": "Sem insumos cadastrados",
"addFirst": "Adicione os custos de insumos do serviço",
"supplyName": "Nome do Insumo",
"supplyPlaceholder": "Ex: Shampoo",
"type": "Tipo",
"unitCost": "Custo Unit.",
"quantity": "Quantidade",
"total": "Total",
"totalCsv": "Total CSV",
"pricePreview": "Prévia do Preço",
"fixedCostPortion": "Porção Gasto Fixo",
"baseCost": "Custo Base",
"finalPrice": "Preço Final",
"includesVat": "Inclui IVA",
"formula": "Fórmula",
"strategicPricing": "Precificação Estratégica",
"competitorPrice": "Preço Concorrente",
"minPrice": "Preço Mínimo",
"maxPrice": "Preço Máximo",
"strategy": "Estratégia",
"strategyAggressive": "Agressivo",
"strategyNeutral": "Neutro",
"strategyPremium": "Premium",
"premiumMultiplier": "Multiplicador Premium",
"targetMargin": "Margem Alvo",
"psychologicalPricing": "Preço Psicológico",
"psychologicalPricingHelp": "Arredondar para terminações em .99",
"noServices": "Nenhum serviço cadastrado",
"createFirst": "Crie sua primeira ficha técnica de serviço",
"empty": "Sem serviços",
"emptyDescription": "Crie sua primeira ficha técnica de serviço para começar",
"noResults": "Nenhum serviço encontrado com os filtros selecionados",
"confirmDelete": "Tem certeza que deseja excluir este serviço?",
"noServiceSettings": "Nenhuma configuração para serviços",
"createServiceSetting": "Crie uma configuração com tipo 'Serviços' ou 'Ambos' para começar",
"duplicate": "Duplicar",
"duplicateSuccess": "Serviço duplicado com sucesso",
"filterCategory": "Filtrar por categoria",
"filterStatus": "Filtrar por status",
"allCategories": "Todas as categorias",
"allStatus": "Todos os status",
"margin": "Margem",
"codePlaceholder": "SRV-001",
"unitPlaceholder": "ml",
"fixedCost": "Custo Fixo",
"price": "Preço",
"itemTypes": {
"supply": "Insumo",
"consumable": "Consumível",
"material": "Material",
"equipmentUsage": "Uso de Equipamento",
"other": "Outros"
}
},
"items": {
"title": "Componentes de Custo",
@ -1181,5 +1361,104 @@
"noSettings": "Sem Configurações",
"createSettingFirst": "Primeiro crie uma configuração de markup na aba Configurações"
}
},
"campaigns": {
"title": "Campanhas Promocionais",
"subtitle": "Gerencie ofertas, descontos e eventos de vendas",
"new": "Nova Campanha",
"create": "Criar Campanha",
"edit": "Editar Campanha",
"delete": "Excluir",
"deleteConfirm": "Tem certeza que deseja excluir a campanha \"{{name}}\"?",
"empty": "Nenhuma campanha promocional",
"createFirst": "Crie sua primeira campanha",
"quickStart": "Início Rápido - Presets",
"discount": "Desconto",
"period": "Período",
"products": "Produtos",
"productsSelected": "produtos selecionados",
"allProducts": "Todos",
"selectAll": "Selecionar Todos",
"deselectAll": "Desmarcar Todos",
"daysRemaining": "Dias Restantes",
"days": "dias",
"status": {
"active": "Ativa",
"scheduled": "Agendada",
"ended": "Encerrada",
"inactive": "Inativa"
},
"filter": {
"all": "Todas",
"active": "Ativas",
"scheduled": "Agendadas",
"ended": "Encerradas",
"inactive": "Inativas"
},
"discountTypes": {
"percentage": "Porcentagem (%)",
"fixed": "Valor Fixo",
"priceOverride": "Preço Fixo"
},
"steps": {
"details": "Detalhes",
"products": "Produtos",
"review": "Revisar"
},
"form": {
"name": "Nome da Campanha",
"namePlaceholder": "Ex: Black Friday 2024",
"code": "Código",
"description": "Descrição",
"discountType": "Tipo de Desconto",
"discountValue": "Valor do Desconto",
"example": "Exemplo",
"minPrice": "Preço Mínimo",
"minPricePlaceholder": "Sem limite",
"minPriceHelp": "O preço promocional não ficará abaixo deste valor",
"profitProtection": "Proteção de Rentabilidade",
"protectAgainstLoss": "Nunca vender com prejuízo",
"protectAgainstLossHelp": "O sistema ajustará automaticamente o preço para que nunca seja menor que o custo (CMV)",
"minMarginPercent": "Margem Mínima (%)",
"minMarginHelp": "Margem mínima obrigatória sobre o custo",
"startDate": "Data Início",
"endDate": "Data Fim",
"startTime": "Hora Início",
"endTime": "Hora Fim",
"isActive": "Campanha Ativa",
"applyToAll": "Aplicar a Todos os Produtos",
"applyToAllHelp": "O desconto será aplicado automaticamente a todos os produtos ativos",
"badgeSettings": "Configuração de Badge",
"showBadge": "Mostrar Badge de Desconto",
"showOriginalPrice": "Mostrar Preço Original Riscado",
"badgeText": "Texto do Badge",
"badgeColor": "Cor do Badge",
"preview": "Pré-visualização"
},
"review": {
"summary": "Resumo da Campanha",
"priceImpact": "Impacto nos Preços",
"totalOriginal": "Total Original",
"totalPromo": "Total Promocional",
"avgDiscount": "Desconto Médio",
"product": "Produto",
"original": "Original",
"promo": "Promo",
"savings": "Economia",
"andMore": "e mais {{count}}...",
"profitWarning": "Atenção! Produtos com prejuízo",
"unprofitableProducts": "{{count}} produto(s) teriam margem negativa sem a proteção",
"pricesProtected": "Preços protegidos",
"protectedProducts": "{{count}} produto(s) tiveram o preço ajustado para manter rentabilidade",
"profitAnalysis": "Análise de Rentabilidade",
"totalProfit": "Lucro Total",
"avgPromoMargin": "Margem Média",
"originalMargin": "Margem Original",
"productDetails": "Detalhe por Produto",
"cmv": "CMV",
"margin": "Margem",
"status": "Status",
"totalCmv": "CMV Total"
}
}
}

View File

@ -1,10 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { businessSettingService, productSheetService } from '../services/api';
import { businessSettingService, productSheetService, serviceSheetService, campaignService } from '../services/api';
import useFormatters from '../hooks/useFormatters';
import BusinessSettingsTab from '../components/business/BusinessSettingsTab';
import ProductSheetsTab from '../components/business/ProductSheetsTab';
import ServiceSheetsTab from '../components/business/ServiceSheetsTab';
import PriceCalculatorTab from '../components/business/PriceCalculatorTab';
import CampaignsTab from '../components/business/CampaignsTab';
const Business = () => {
const { t } = useTranslation();
@ -13,6 +15,7 @@ const Business = () => {
const [activeTab, setActiveTab] = useState('settings');
const [settings, setSettings] = useState([]);
const [sheets, setSheets] = useState([]);
const [serviceSheets, setServiceSheets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@ -21,12 +24,14 @@ const Business = () => {
setLoading(true);
setError(null);
try {
const [settingsData, sheetsData] = await Promise.all([
const [settingsData, sheetsData, serviceSheetsData] = await Promise.all([
businessSettingService.getAll(),
productSheetService.getAll(),
serviceSheetService.getAll(),
]);
setSettings(settingsData);
setSheets(sheetsData);
setServiceSheets(serviceSheetsData);
} catch (err) {
console.error('Error loading business data:', err);
setError(err.response?.data?.message || t('common.error'));
@ -52,7 +57,7 @@ const Business = () => {
setSettings(prev => prev.filter(s => s.id !== id));
};
// Handlers para Sheets
// Handlers para Sheets (Products)
const handleSheetCreated = (newSheet) => {
setSheets(prev => [...prev, newSheet]);
};
@ -65,9 +70,28 @@ const Business = () => {
setSheets(prev => prev.filter(s => s.id !== id));
};
// Handlers para Service Sheets
const handleServiceSheetCreated = (newSheet) => {
setServiceSheets(prev => [...prev, newSheet]);
};
const handleServiceSheetUpdated = (updatedSheet) => {
setServiceSheets(prev => prev.map(s => s.id === updatedSheet.id ? updatedSheet : s));
};
const handleServiceSheetDeleted = (id) => {
setServiceSheets(prev => prev.filter(s => s.id !== id));
};
// Filter settings by business type for products and services
const productSettings = settings.filter(s => s.business_type === 'products' || s.business_type === 'both' || !s.business_type);
const serviceSettings = settings.filter(s => s.business_type === 'services' || s.business_type === 'both');
const tabs = [
{ id: 'settings', label: t('business.tabs.settings'), icon: 'bi-gear' },
{ id: 'products', label: t('business.tabs.products'), icon: 'bi-box-seam' },
{ id: 'services', label: t('business.tabs.services'), icon: 'bi-person-workspace' },
{ id: 'campaigns', label: t('business.tabs.campaigns'), icon: 'bi-megaphone' },
{ id: 'calculator', label: t('business.tabs.calculator'), icon: 'bi-calculator' },
];
@ -144,13 +168,29 @@ const Business = () => {
{activeTab === 'products' && (
<ProductSheetsTab
sheets={sheets}
settings={settings}
settings={productSettings}
onCreated={handleSheetCreated}
onUpdated={handleSheetUpdated}
onDeleted={handleSheetDeleted}
/>
)}
{activeTab === 'services' && (
<ServiceSheetsTab
sheets={serviceSheets}
settings={serviceSettings}
onCreated={handleServiceSheetCreated}
onUpdated={handleServiceSheetUpdated}
onDeleted={handleServiceSheetDeleted}
/>
)}
{activeTab === 'campaigns' && (
<CampaignsTab
sheets={sheets}
/>
)}
{activeTab === 'calculator' && (
<PriceCalculatorTab
settings={settings}

View File

@ -1127,4 +1127,159 @@ export const productSheetService = {
},
};
// ============================================
// Fichas Técnicas de Serviços (Service Sheets)
// ============================================
export const serviceSheetService = {
// Listar todas as fichas de serviço
getAll: async (params = {}) => {
const response = await api.get('/service-sheets', { params });
return response.data;
},
// Obter uma ficha específica
get: async (id) => {
const response = await api.get(`/service-sheets/${id}`);
return response.data;
},
// Criar nova ficha
create: async (data) => {
const response = await api.post('/service-sheets', data);
return response.data;
},
// Atualizar ficha
update: async (id, data) => {
const response = await api.put(`/service-sheets/${id}`, data);
return response.data;
},
// Excluir ficha
delete: async (id) => {
const response = await api.delete(`/service-sheets/${id}`);
return response.data;
},
// Adicionar item/insumo à ficha
addItem: async (sheetId, itemData) => {
const response = await api.post(`/service-sheets/${sheetId}/items`, itemData);
return response.data;
},
// Atualizar item da ficha
updateItem: async (sheetId, itemId, itemData) => {
const response = await api.put(`/service-sheets/${sheetId}/items/${itemId}`, itemData);
return response.data;
},
// Remover item da ficha
removeItem: async (sheetId, itemId) => {
const response = await api.delete(`/service-sheets/${sheetId}/items/${itemId}`);
return response.data;
},
// Duplicar ficha
duplicate: async (id) => {
const response = await api.post(`/service-sheets/${id}/duplicate`);
return response.data;
},
// Listar categorias
getCategories: async () => {
const response = await api.get('/service-sheets/categories');
return response.data;
},
// Listar tipos de itens
getItemTypes: async () => {
const response = await api.get('/service-sheets/item-types');
return response.data;
},
// Simular preço
simulate: async (data) => {
const response = await api.post('/service-sheets/simulate', data);
return response.data;
},
};
// ============================================
// Campanhas Promocionais (Promotional Campaigns)
// ============================================
export const campaignService = {
// Listar todas as campanhas
getAll: async (params = {}) => {
const response = await api.get('/campaigns', { params });
return response.data;
},
// Obter uma campanha específica
getById: async (id) => {
const response = await api.get(`/campaigns/${id}`);
return response.data;
},
// Criar nova campanha
create: async (data) => {
const response = await api.post('/campaigns', data);
return response.data;
},
// Atualizar campanha
update: async (id, data) => {
const response = await api.put(`/campaigns/${id}`, data);
return response.data;
},
// Excluir campanha
delete: async (id) => {
const response = await api.delete(`/campaigns/${id}`);
return response.data;
},
// Duplicar campanha
duplicate: async (id) => {
const response = await api.post(`/campaigns/${id}/duplicate`);
return response.data;
},
// Adicionar produtos à campanha
addProducts: async (campaignId, productIds) => {
const response = await api.post(`/campaigns/${campaignId}/products`, {
product_ids: productIds,
});
return response.data;
},
// Remover produtos da campanha
removeProducts: async (campaignId, productIds) => {
const response = await api.delete(`/campaigns/${campaignId}/products`, {
data: { product_ids: productIds },
});
return response.data;
},
// Atualizar desconto de um produto na campanha
updateProductDiscount: async (campaignId, productId, discountData) => {
const response = await api.put(
`/campaigns/${campaignId}/products/${productId}`,
discountData
);
return response.data;
},
// Obter presets disponíveis
getPresets: async () => {
const response = await api.get('/campaigns/presets');
return response.data;
},
// Preview de preços com desconto
preview: async (data) => {
const response = await api.post('/campaigns/preview', data);
return response.data;
},
};
export default api;

18
remote_migrate.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
cd /var/www/webmoney
# Check if tables exist
echo "=== Checking tables ==="
mysql -u webmoney -p'M@ster9354' webmoney -e "SHOW TABLES LIKE '%service%';"
# Run migrations
echo "=== Running migrations ==="
php artisan migrate --force
# Clear caches
echo "=== Clearing caches ==="
php artisan config:clear
php artisan cache:clear
php artisan route:clear
echo "=== Done ==="