v1.30.0: Fichas de Servicios, Horas Productivas, i18n completo, fix modal responsivo
This commit is contained in:
parent
84d9d7d187
commit
1be3355a00
317
ANALISE_PRECIFICACAO.md
Normal file
317
ANALISE_PRECIFICACAO.md
Normal 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*
|
||||
111
CHANGELOG.md
111
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
431
backend/app/Http/Controllers/Api/ServiceSheetController.php
Normal file
431
backend/app/Http/Controllers/Api/ServiceSheetController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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 já 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,
|
||||
|
||||
@ -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)',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
419
backend/app/Models/PromotionalCampaign.php
Normal file
419
backend/app/Models/PromotionalCampaign.php
Normal 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';
|
||||
}
|
||||
}
|
||||
281
backend/app/Models/ServiceSheet.php
Normal file
281
backend/app/Models/ServiceSheet.php
Normal 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);
|
||||
}
|
||||
}
|
||||
63
backend/app/Models/ServiceSheetItem.php
Normal file
63
backend/app/Models/ServiceSheetItem.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
288
backend/database/seeders/BusinessExampleSeeder.php
Normal file
288
backend/database/seeders/BusinessExampleSeeder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
113
backend/database/seeders/BusinessSeeder.php
Normal file
113
backend/database/seeders/BusinessSeeder.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
@ -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
219
deploy.ps1
Normal 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
418
docs/MODULO_NEGOCIOS.txt
Normal 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
2
fix_migration.sh
Normal 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
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
933
frontend/src/components/business/CampaignModal.jsx
Normal file
933
frontend/src/components/business/CampaignModal.jsx
Normal 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;
|
||||
327
frontend/src/components/business/CampaignsTab.jsx
Normal file
327
frontend/src/components/business/CampaignsTab.jsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
713
frontend/src/components/business/ServiceSheetModal.jsx
Normal file
713
frontend/src/components/business/ServiceSheetModal.jsx
Normal 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;
|
||||
317
frontend/src/components/business/ServiceSheetsTab.jsx
Normal file
317
frontend/src/components/business/ServiceSheetsTab.jsx
Normal 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;
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
18
remote_migrate.sh
Normal 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 ==="
|
||||
Loading…
Reference in New Issue
Block a user