Atualização 4 | Nova tela de dash do GR, ajustes sobre dispesas no finaceiro, novas telas de gestão de contratos de esperiencia e ferias, ferias ainda sendo um esboço.
This commit is contained in:
parent
538a75092d
commit
de7264d12d
|
|
@ -35,5 +35,10 @@
|
|||
"feature": "Ajuste do painel Status de Cobran\u00e7a: centro com total de boletos e legenda vertical com quantitativo e tipografia fluida.",
|
||||
"status": "active",
|
||||
"timestamp": "2026-02-08"
|
||||
},
|
||||
{
|
||||
"feature": "Implementado seletor de per\u00edodo (Anual/Mensal) no dashboard do GR com filtros de m\u00eas e ano.",
|
||||
"status": "active",
|
||||
"timestamp": "2026-02-08"
|
||||
}
|
||||
]
|
||||
82
README.md
82
README.md
|
|
@ -124,27 +124,79 @@ PlatformSistemas/
|
|||
└── package.json # Dependências e scripts
|
||||
```
|
||||
|
||||
## 🎯 Módulos e Features
|
||||
## 🎯 Módulos e Ambientes
|
||||
|
||||
### 💰 Financeiro
|
||||
- **financeiro-cnab**: Sistema completo de CNAB para geração de remessas, gestão de favorecidos e pagamentos
|
||||
- **financeiro-v2**: Módulo avançado de contas a receber/pagar, conciliação e fluxo de caixa
|
||||
- **workspace**: Workspace financeiro com gestão de receitas, despesas e conciliação
|
||||
### 👥 RH (Recursos Humanos)
|
||||
Módulo desenvolvido para atender integralmente as demandas dos setores de RH e Departamento Pessoal.
|
||||
- **Funcionalidades**: Gestão de funcionários, controle de ponto eletrônico, processamento de benefícios e cálculos trabalhistas.
|
||||
- **Destaque**: Dashboards de contratos de experiência e acompanhamento de admissões.
|
||||
|
||||
### 🚛 Frota
|
||||
- **fleet-v2**: Gestão completa de frota com dashboard, manutenções, abastecimentos e monitoramento
|
||||
### 💰 CNAB (Financeiro Remessas)
|
||||
Módulo financeiro especializado na interface bancária e automação de pagamentos.
|
||||
- **Funcionalidades**: Geração e gerenciamento de remessas CNAB, cadastro de favorecidos, processamento de arquivos de retorno e validação de pagamentos TED/PIX.
|
||||
|
||||
### 👥 Recursos Humanos
|
||||
- **rh**: Sistema de RH com ponto eletrônico, gestão de funcionários, benefícios e cálculos
|
||||
### 🚛 Prafrota (Gestão de Frota)
|
||||
Sistema robusto para o controle operacional de frotas de veículos.
|
||||
- **Funcionalidades**: Cadastro técnico de veículos, monitoramento em tempo real, gestão de manutenções preventivas/corretivas e controle de abastecimentos.
|
||||
|
||||
### 📋 Gestão de Registros
|
||||
- **gr**: Sistema de gestão de registros com Kanban, cadastro de motoristas e contratos
|
||||
### 🏦 Financeiro_V2 + Workspace
|
||||
Solução avançada para gestão financeira estratégica e operacional.
|
||||
- **Funcionalidades**: Gestão de contas a pagar e receber, fluxo de caixa projetado vs. realizado, conciliação bancária automatizada e dashboards de indicadores financeiros (KPIs).
|
||||
|
||||
### 🔬 AutoLab
|
||||
- **autolab**: Gestão de laboratório com estoque, vendas e configurações
|
||||
### 🍊 OestePan (Customização Prafrota)
|
||||
Segmento especializado do Prafrota customizado especificamente para as necessidades logísticas da Oeste Pan.
|
||||
- **Funcionalidades**: Além das funções base do Prafrota, inclui integração com Moki para checklists, gestão específica de sinistros e visualização otimizada para a operação do cliente.
|
||||
|
||||
### 🛠️ Desenvolvimento
|
||||
- **dev-tools**: Playground para testes de componentes e debug
|
||||
### 🔬 AutoLab (Gestão de Oficinas)
|
||||
Módulo em fase de finalização focado na operação técnica de laboratórios e oficinas.
|
||||
- **Funcionalidades**: Controle de estoque de peças, gestão de ordens de serviço (vendas), cadastro de clientes e configurações técnicas de atendimento.
|
||||
|
||||
---
|
||||
|
||||
## 🛣️ Estrutura de Rotas e Variáveis
|
||||
|
||||
Cada ambiente consome uma API RESTful estruturada para operações de CRUD (Apresentação, Edição e Exclusão).
|
||||
|
||||
### Padrão de Endpoints
|
||||
As rotas seguem a nomenclatura do recurso base (ex: `/prafrot`, `/workspace`):
|
||||
|
||||
- **Apresentação (GET)**:
|
||||
- `/recurso/apresentar`: Lista principal de dados.
|
||||
- `/recurso/listagem`: Alternativa para listagens simplificadas.
|
||||
- `/recurso/:id`: Detalhamento de um registro específico.
|
||||
- **Edição/Atualização (PUT/PATCH)**:
|
||||
- `/recurso/edit`: Atualização de campos específicos.
|
||||
- `/recurso/:id`: Atualização completa do registro.
|
||||
- `/recurso/edit/status_global`: Utilizado em Kanbans para movimentação de cards.
|
||||
- **Exclusão (DELETE)**:
|
||||
- `/recurso/delete/:id`: Remoção lógica ou física do registro.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design System & Playground
|
||||
|
||||
O projeto utiliza um laboratório de componentes (**Playground**) para garantir consistência visual em todos os ambientes.
|
||||
|
||||
### Componentes em Uso (Playground)
|
||||
Estes componentes são extraídos do Design System e aplicados nos módulos:
|
||||
|
||||
| Componente | Função | Ambientes Principais |
|
||||
| :--- | :--- | :--- |
|
||||
| **ExcelTable** | Tabela de alta performance com filtros | RH, Prafrota, Financeiro |
|
||||
| **ItemDetailPanel** | Painel lateral para visualização de detalhes | Prafrota, OestePan |
|
||||
| **DashboardKPICard** | Cards de indicadores com micro-gráficos | Financeiro_V2, Workspace |
|
||||
| **StatsGrid** | Grid de estatísticas rápidas | RH, Prafrota |
|
||||
| **KanbanBoard** | Gestão visual de fluxos e status | GR, RH |
|
||||
| **AutoFillInput** | Inputs inteligentes com busca em tempo real | Workspace, Financeiro |
|
||||
| **StatusBadge** | Badges coloridos de status dinâmico | Todos |
|
||||
|
||||
### Componentes Disponíveis (Candidatos)
|
||||
Componentes presentes no laboratório mas com uso restrito ou em homologação:
|
||||
- `ThemeTuner`: Ferramenta de ajuste de temas em runtime.
|
||||
- `FinesCard`: Card especializado para visualização de multas.
|
||||
- `SmartTable`: Versão experimental de tabelas com auto-ajuste.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tecnologias
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
|||
export const AutoFillInput = ({
|
||||
label,
|
||||
placeholder = "Comece a digitar para pesquisar...",
|
||||
value = '',
|
||||
data = [],
|
||||
apiRoute,
|
||||
filterField = "name",
|
||||
|
|
@ -28,12 +29,18 @@ export const AutoFillInput = ({
|
|||
className,
|
||||
icon: Icon = Search
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [query, setQuery] = useState(value);
|
||||
|
||||
// Sincroniza o valor externo com o estado interno (útil para edição)
|
||||
useEffect(() => {
|
||||
setQuery(value || '');
|
||||
}, [value]);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Fecha a lista ao clicar fora do componente
|
||||
useEffect(() => {
|
||||
|
|
@ -58,7 +65,12 @@ export const AutoFillInput = ({
|
|||
).slice(0, 10);
|
||||
|
||||
setSuggestions(results);
|
||||
setIsOpen(true);
|
||||
|
||||
// Só abre automaticamente se o input estiver focado
|
||||
if (document.activeElement === inputRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
|
|
@ -109,6 +121,7 @@ export const AutoFillInput = ({
|
|||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const useCnabStore = create((set) => ({
|
|||
setPaymentMode: (mode) => set({ paymentMode: mode }),
|
||||
selectedBank: 'INTER', // 'INTER' ou 'BRADESCO'
|
||||
setSelectedBank: (bank) => set({ selectedBank: bank }),
|
||||
/** Bradesco TED: 'ted' = rota /cnab/bradesco/ted, 'folha_pagamento' = rota /cnab/bradesco/folha_pagamento */
|
||||
bradescoRemessaVariant: 'ted', // 'ted' | 'folha_pagamento'
|
||||
/** Bradesco TED: 'ted' = rota /cnab/bradesco/ted, 'folha_pagamento' = rota /cnab/bradesco/folha_pagamento, 'cobranca' = rota /cnab/bradesco/cobranca */
|
||||
bradescoRemessaVariant: 'ted', // 'ted' | 'folha_pagamento' | 'cobranca'
|
||||
setBradescoRemessaVariant: (variant) => set({ bradescoRemessaVariant: variant }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -158,6 +158,22 @@ const MODEL_SCHEMAS = {
|
|||
{ key: 'TIPO_CONTA_FAVORECIDA', label: 'Tipo Conta Favorecida', required: false },
|
||||
{ key: 'FINALIDADE_TED', label: 'Finalidade TED', required: false },
|
||||
{ key: 'INFORMACAO', label: 'Informação', required: false }
|
||||
],
|
||||
BRADESCO_COBRANCA: [
|
||||
{ key: 'VALOR', label: 'Valor Título', required: true },
|
||||
{ key: 'VENCIMENTO', label: 'Data Vencimento', required: true },
|
||||
{ key: 'NOSSO_NUMERO', label: 'Nosso Número', required: true },
|
||||
{ key: 'NUMERO_DOCUMENTO', label: 'Número Documento', required: true },
|
||||
{ key: 'NOME_PAGADOR', label: 'Nome Pagador', required: true },
|
||||
{ key: 'CPF_CNPJ_PAGADOR', label: 'CPF/CNPJ Pagador', required: true },
|
||||
{ key: 'ENDERECO', label: 'Endereço', required: true },
|
||||
{ key: 'BAIRRO', label: 'Bairro', required: true },
|
||||
{ key: 'CEP', label: 'CEP', required: true },
|
||||
{ key: 'CIDADE', label: 'Cidade', required: true },
|
||||
{ key: 'UF', label: 'UF', required: true },
|
||||
{ key: 'DATA_EMISSAO', label: 'Data Emissão', required: true },
|
||||
{ key: 'USO_EMPRESA', label: 'Uso Empresa', required: false },
|
||||
{ key: 'ESPECIE_TITULO', label: 'Espécie Título', required: false }
|
||||
]
|
||||
};
|
||||
|
||||
|
|
@ -167,9 +183,15 @@ const MODEL_SCHEMAS = {
|
|||
* 1. Upload → 2. Mapping → 3. Validate Columns → 4. Validate JSON → 5. Generate Excel → 6. Generate REM
|
||||
*/
|
||||
export const useGerarRemessa = () => {
|
||||
const { selectedBank, paymentMode, bradescoRemessaVariant } = useCnabStore(); // Banco, modo de pagamento e variante TED Bradesco (ted | folha_pagamento)
|
||||
const { selectedBank, paymentMode, bradescoRemessaVariant } = useCnabStore(); // Banco, modo de pagamento e variante TED Bradesco (ted | folha_pagamento | cobranca)
|
||||
const [step, setStep] = useState('upload'); // upload | mapping | validate | success
|
||||
const paymentType = paymentMode; // Usa o paymentMode do store ao invés de estado local
|
||||
|
||||
// Calcula o tipo de pagamento efetivo para seleção do schema
|
||||
const effectivePaymentType = paymentMode === 'TED' && selectedBank === 'BRADESCO' && bradescoRemessaVariant === 'cobranca'
|
||||
? 'BRADESCO_COBRANCA'
|
||||
: paymentMode;
|
||||
|
||||
const paymentType = paymentMode;
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Dados do arquivo
|
||||
|
|
@ -282,7 +304,7 @@ export const useGerarRemessa = () => {
|
|||
console.group('🔗 Mapeamento automático de colunas');
|
||||
console.log('📊 Headers disponíveis:', rawHeaders);
|
||||
|
||||
MODEL_SCHEMAS[paymentType].forEach(field => {
|
||||
MODEL_SCHEMAS[effectivePaymentType].forEach(field => {
|
||||
// Tenta múltiplas estratégias de matching
|
||||
const fieldKey = clean(field.key);
|
||||
const fieldKeyParts = field.key.split('_').map(p => clean(p)).filter(p => p.length > 2);
|
||||
|
|
@ -378,7 +400,7 @@ export const useGerarRemessa = () => {
|
|||
// Converte os dados brutos para o formato esperado pelo backend
|
||||
const mappedData = rawData.map((row, idx) => {
|
||||
const newRow = { id: row.id || idx + 1 };
|
||||
MODEL_SCHEMAS[paymentType].forEach(field => {
|
||||
MODEL_SCHEMAS[effectivePaymentType].forEach(field => {
|
||||
const sourceCol = columnMapping[field.key];
|
||||
newRow[field.key] = sourceCol ? (row[sourceCol] || '') : '';
|
||||
});
|
||||
|
|
@ -822,6 +844,7 @@ export const useGerarRemessa = () => {
|
|||
finalize,
|
||||
downloadRemessa,
|
||||
reset,
|
||||
schemas: MODEL_SCHEMAS
|
||||
schemas: MODEL_SCHEMAS,
|
||||
effectivePaymentType
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -738,6 +738,9 @@ const cnabService = {
|
|||
} else if (remessaVariant === 'folha_pagamento') {
|
||||
endpoint = '/cnab/bradesco/folha_pagamento';
|
||||
console.log('🏦 [CNAB Service] Usando rota Folha de Pagamento (Bradesco)');
|
||||
} else if (remessaVariant === 'cobranca') {
|
||||
endpoint = '/cnab/bradesco/cobranca';
|
||||
console.log('🏦 [CNAB Service] Usando rota Cobrança (Bradesco)');
|
||||
} else {
|
||||
endpoint = '/cnab/bradesco/ted';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ const GerarRemessaView = () => {
|
|||
finalize,
|
||||
downloadRemessa,
|
||||
reset,
|
||||
schemas
|
||||
schemas,
|
||||
effectivePaymentType
|
||||
} = useGerarRemessa();
|
||||
|
||||
const { selectedBank, setSelectedBank, bradescoRemessaVariant, setBradescoRemessaVariant } = useCnabStore();
|
||||
|
|
@ -189,9 +190,9 @@ const GerarRemessaView = () => {
|
|||
* DEVE SER DEFINIDO DEPOIS DE processedValidatedData E validationInfo
|
||||
*/
|
||||
const validatedColumns = useMemo(() => {
|
||||
if (!schemas[paymentType]) return [];
|
||||
if (!schemas[effectivePaymentType]) return [];
|
||||
|
||||
return schemas[paymentType].map(field => ({
|
||||
return schemas[effectivePaymentType].map(field => ({
|
||||
header: field.label,
|
||||
field: field.key,
|
||||
width: 180,
|
||||
|
|
@ -340,7 +341,8 @@ const GerarRemessaView = () => {
|
|||
<div className="bg-white dark:bg-zinc-900/50 p-1 rounded-xl flex border border-slate-200 dark:border-zinc-800 shadow-inner">
|
||||
{[
|
||||
{ value: 'ted', label: 'TED' },
|
||||
{ value: 'folha_pagamento', label: 'Folha de pagamento' }
|
||||
{ value: 'folha_pagamento', label: 'Folha' },
|
||||
{ value: 'cobranca', label: 'Cobrança' }
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
|
|
@ -638,7 +640,7 @@ const GerarRemessaView = () => {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-zinc-800 text-slate-700 dark:text-zinc-400">
|
||||
{schemas[paymentType].map(f => (
|
||||
{schemas[effectivePaymentType].map(f => (
|
||||
<tr key={f.key}>
|
||||
<td className="px-6 py-4 font-mono text-emerald-500 dark:text-emerald-400 font-bold">{f.key}</td>
|
||||
<td className="px-6 py-4">{f.label}</td>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ const CaixinhaDetailsModal = ({ isOpen, onClose, caixinhaId, caixinhaName, mes,
|
|||
<div className="text-right">
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Lançamentos</p>
|
||||
<Badge variant="secondary" className="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 font-bold">
|
||||
{data.total_transacoes || 0}
|
||||
{data.total_transacoes || (data.categorias || []).reduce((acc, cat) => acc + (cat.total_transacoes || 0), 0)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -125,7 +125,7 @@ const CaixinhaDetailsModal = ({ isOpen, onClose, caixinhaId, caixinhaName, mes,
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.categorias.map((cat, idx) => (
|
||||
{(data?.categorias || []).map((cat, idx) => (
|
||||
<div key={idx} className="border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden bg-white dark:bg-slate-900 shadow-sm transition-all duration-300">
|
||||
<button
|
||||
onClick={() => toggleCategory(cat.categoria)}
|
||||
|
|
@ -147,7 +147,7 @@ const CaixinhaDetailsModal = ({ isOpen, onClose, caixinhaId, caixinhaName, mes,
|
|||
</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-slate-400 font-medium">
|
||||
{cat.total_transacoes} transações
|
||||
{cat.total_transacoes || (cat.transacoes?.length || 0)} lançamentos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -164,7 +164,128 @@ const CaixinhaDetailsModal = ({ isOpen, onClose, caixinhaId, caixinhaName, mes,
|
|||
|
||||
{expandedCategories[cat.categoria] && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-800 bg-slate-50/30 dark:bg-slate-900/50 p-4 space-y-3">
|
||||
{cat.beneficiarios.map((benef, bIdx) => {
|
||||
{/* Suporte para novas estruturas (subgrupos OU flat transacoes) OU estrutura antiga (beneficiarios) */}
|
||||
{cat.subgrupos && cat.subgrupos.length > 0 ? (
|
||||
cat.subgrupos.map((sub, sIdx) => {
|
||||
const subKey = `${cat.categoria}-${sub.nome_agrupamento}`;
|
||||
return (
|
||||
<div key={sIdx} className="bg-white dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-700 overflow-hidden shadow-sm">
|
||||
<button
|
||||
onClick={() => toggleBeneficiary(subKey)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
{sub.nome_agrupamento || 'Não informado'}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[9px] font-bold border-slate-200 text-slate-500 h-4">
|
||||
{sub.total_transacoes || (sub.transacoes?.length || 0)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-bold text-slate-600 dark:text-slate-400 font-mono">
|
||||
{formatCurrency(sub.valor_total || 0)}
|
||||
</span>
|
||||
{expandedBeneficiaries[subKey] ? <ChevronDown className="w-3 h-3 text-slate-400" /> : <ChevronRight className="w-3 h-3 text-slate-400" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expandedBeneficiaries[subKey] && (
|
||||
<div className="p-2 bg-slate-50/50 dark:bg-slate-900/30">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-slate-200 dark:border-slate-800">
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase">Data</TableHead>
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase">Descrição</TableHead>
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase">Tipo</TableHead>
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase text-right">Valor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(sub.transacoes || []).map((t, tIdx) => (
|
||||
<TableRow key={tIdx} className="hover:bg-white dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800/50">
|
||||
<TableCell className="text-[11px] py-2 text-slate-500 font-medium whitespace-nowrap">
|
||||
{t.dataEntrada || t.data || ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] py-2 text-slate-700 dark:text-slate-300 font-medium max-w-[200px] truncate">
|
||||
{t.descricao}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{t.tipoOperacao === 'C' ?
|
||||
<ArrowUpRight className="w-3 h-3 text-emerald-500" /> :
|
||||
<ArrowDownRight className="w-3 h-3 text-rose-500" />
|
||||
}
|
||||
<span className={cn(
|
||||
"font-bold",
|
||||
t.tipoOperacao === 'C' ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
|
||||
)}>
|
||||
{t.tipoOperacao === 'C' ? 'Entrada' : 'Saída'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cn(
|
||||
"text-[11px] py-2 text-right font-bold font-mono",
|
||||
t.tipoOperacao === 'C' ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
|
||||
)}>
|
||||
{formatCurrency(t.valor)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : cat.transacoes && cat.transacoes.length > 0 ? (
|
||||
<div className="p-2 bg-white dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-700 overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-slate-200 dark:border-slate-800">
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase">Data</TableHead>
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase">Descrição</TableHead>
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase">Tipo</TableHead>
|
||||
<TableHead className="text-[10px] h-8 font-bold text-slate-500 uppercase text-right">Valor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cat.transacoes.map((t, tIdx) => (
|
||||
<TableRow key={tIdx} className="hover:bg-white dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800/50">
|
||||
<TableCell className="text-[11px] py-2 text-slate-500 font-medium whitespace-nowrap">
|
||||
{t.dataEntrada || t.data || ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] py-2 text-slate-700 dark:text-slate-300 font-medium max-w-[200px] truncate">
|
||||
{t.descricao}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{t.tipoOperacao === 'C' ?
|
||||
<ArrowUpRight className="w-3 h-3 text-emerald-500" /> :
|
||||
<ArrowDownRight className="w-3 h-3 text-rose-500" />
|
||||
}
|
||||
<span className={cn(
|
||||
"font-bold",
|
||||
t.tipoOperacao === 'C' ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
|
||||
)}>
|
||||
{t.tipoOperacao === 'C' ? 'Entrada' : 'Saída'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cn(
|
||||
"text-[11px] py-2 text-right font-bold font-mono",
|
||||
t.tipoOperacao === 'C' ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
|
||||
)}>
|
||||
{formatCurrency(t.valor)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (cat.beneficiarios || []).map((benef, bIdx) => {
|
||||
const benefKey = `${cat.categoria}-${benef.beneficiario}`;
|
||||
return (
|
||||
<div key={bIdx} className="bg-white dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-700 overflow-hidden shadow-sm">
|
||||
|
|
@ -201,10 +322,10 @@ const CaixinhaDetailsModal = ({ isOpen, onClose, caixinhaId, caixinhaName, mes,
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{benef.transacoes.map((t, tIdx) => (
|
||||
{(benef.transacoes || []).map((t, tIdx) => (
|
||||
<TableRow key={tIdx} className="hover:bg-white dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800/50">
|
||||
<TableCell className="text-[11px] py-2 text-slate-500 font-medium">
|
||||
{formatDate(t.dataEntrada).split(' ')[0]}
|
||||
<TableCell className="text-[11px] py-2 text-slate-500 font-medium whitespace-nowrap">
|
||||
{t.dataEntrada || t.data || ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] py-2 text-slate-700 dark:text-slate-300 font-medium max-w-[200px] truncate">
|
||||
{t.descricao}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export function CategorizacaoDialog({
|
|||
descricao: '',
|
||||
categoria: '',
|
||||
caixa: '',
|
||||
beneficiario: ''
|
||||
beneficiario: '',
|
||||
tag: ''
|
||||
});
|
||||
const [isCriarRegraOpen, setIsCriarRegraOpen] = React.useState(false);
|
||||
const [regraFormData, setRegraFormData] = React.useState({
|
||||
|
|
@ -52,7 +53,8 @@ export function CategorizacaoDialog({
|
|||
descricao: transacao.descricao || '',
|
||||
categoria: (transacao.categoriaId ?? transacao.categoria ?? '').toString(),
|
||||
caixa: (caixaId !== '' && caixaId !== '0') ? caixaId : '',
|
||||
beneficiario: transacao.beneficiario || transacao.beneficiario_pagador || ''
|
||||
beneficiario: transacao.beneficiario || transacao.beneficiario_pagador || '',
|
||||
tag: transacao.tag || transacao.raw?.tag || ''
|
||||
});
|
||||
}
|
||||
}, [transacao, isOpen]);
|
||||
|
|
@ -97,7 +99,8 @@ export function CategorizacaoDialog({
|
|||
categoriaId: parseInt(formData.categoria),
|
||||
caixaId: parseInt(formData.caixa),
|
||||
beneficiario: formData.beneficiario,
|
||||
descricao: formData.descricao
|
||||
descricao: formData.descricao,
|
||||
tag: formData.tag
|
||||
});
|
||||
toast.success('Transação categorizada com sucesso!', 'Sucesso');
|
||||
onOpenChange(false);
|
||||
|
|
@ -251,6 +254,22 @@ export function CategorizacaoDialog({
|
|||
className="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold text-slate-700 dark:text-slate-300">Tag da Transação</Label>
|
||||
<Select
|
||||
value={formData.tag}
|
||||
onValueChange={(val) => setFormData(prev => ({ ...prev, tag: val }))}
|
||||
>
|
||||
<SelectTrigger className="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white">
|
||||
<SelectValue placeholder="Selecione uma tag..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800">
|
||||
<SelectItem value="operacional" className="text-slate-900 dark:text-slate-100 focus:bg-slate-100 dark:focus:bg-slate-800 cursor-pointer">Operacional</SelectItem>
|
||||
<SelectItem value="avulsa" className="text-slate-900 dark:text-slate-100 focus:bg-slate-100 dark:focus:bg-slate-800 cursor-pointer">Avulsa</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import AdvancedFiltersModal from './AdvancedFiltersModal';
|
|||
|
||||
function ExcelTable({
|
||||
data = [],
|
||||
columns,
|
||||
columns = [],
|
||||
filterDefs = [],
|
||||
onEdit,
|
||||
onDelete,
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ export function TransactionDetailModal({
|
|||
<InfoField label="Status Atual" value={isConciliado ? 'CONCILIADO' : 'PENDENTE'} icon={CheckCircle2} className="col-span-2" />
|
||||
<InfoField label="Categoria" value={categoriaNome} icon={Tag} />
|
||||
<InfoField label="Caixinha / Destino" value={caixinhaNome} icon={Banknote} />
|
||||
<InfoField label="Tag da Transação" value={transaction.tag} icon={Tag} className="col-span-2" />
|
||||
<InfoField label="Regra Aplicada" value={transaction.regra ? 'ID #' + transaction.regra : null} icon={Settings} className="col-span-2" />
|
||||
<InfoField label="Data de Emissão" value={transaction.dataEmissao} icon={Calendar} />
|
||||
<InfoField label="Data de Vencimento" value={transaction.dataVencimento} icon={Calendar} />
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ const useConciliacao = create((set, get) => ({
|
|||
externalRuleId,
|
||||
externalCategoryId,
|
||||
isReconciled: !!(externalRuleId && externalRuleId !== "0") || !!(externalCategoryId && externalCategoryId !== "0"),
|
||||
tag: t.tag || '',
|
||||
raw: t
|
||||
};
|
||||
});
|
||||
|
|
@ -203,21 +204,17 @@ const useConciliacao = create((set, get) => ({
|
|||
return [];
|
||||
},
|
||||
|
||||
reconcileTransaction: async (data) => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
// The API expects individual updates or a structured payload
|
||||
// Based on the legacy code provided, it calls updateTransactionCategory, updateTransactionBox, etc.
|
||||
// Or a single updateTransactionRule if it's considered a rule update.
|
||||
// The user JSON suggests updating category, box, beneficiary, and description.
|
||||
reconcileTransaction: async (data) => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
const { idextrato, categoria, caixinha, beneficiario_pagador, tag } = data;
|
||||
|
||||
const { idextrato, categoria, caixinha, beneficiario_pagador } = data;
|
||||
|
||||
await Promise.all([
|
||||
conciliacaoService.updateTransactionCategory(idextrato, categoria),
|
||||
conciliacaoService.updateTransactionBox(idextrato, caixinha || "0"),
|
||||
conciliacaoService.updateTransactionBeneficiary(idextrato, beneficiario_pagador)
|
||||
]);
|
||||
await Promise.all([
|
||||
conciliacaoService.updateTransactionCategory(idextrato, categoria),
|
||||
conciliacaoService.updateTransactionBox(idextrato, caixinha || "0"),
|
||||
conciliacaoService.updateTransactionBeneficiary(idextrato, beneficiario_pagador),
|
||||
tag ? conciliacaoService.updateTransactionTag(idextrato, tag) : Promise.resolve()
|
||||
]);
|
||||
|
||||
await get().loadData();
|
||||
set({ isLoading: false });
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
const [caminhoNavegacao, setCaminhoNavegacao] = useState([]);
|
||||
const [caixaSelecionado, setCaixaSelecionado] = useState(null);
|
||||
const [categoriaSelecionada, setCategoriaSelecionada] = useState(null);
|
||||
const [detalheSelecionado, setDetalheSelecionado] = useState(null);
|
||||
const [subgroupSelecionado, setSubgroupSelecionado] = useState(null);
|
||||
const [dadosNivelAtual, setDadosNivelAtual] = useState([]);
|
||||
const [backendNavData, setBackendNavData] = useState([]);
|
||||
const [isNavLoading, setIsNavLoading] = useState(false);
|
||||
|
||||
|
|
@ -119,11 +120,11 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
const recarregarDados = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const extrato = await workspaceConciliacaoService.fetchExtrato();
|
||||
setExtratoCompleto(Array.isArray(extrato) ? extrato : []);
|
||||
const extrato = (await workspaceConciliacaoService.fetchExtrato({ mes: filtroMes, ano: filtroAno })) || [];
|
||||
setExtratoCompleto(extrato);
|
||||
// Separar transações conciliadas e não categorizadas
|
||||
const conciliadas = extrato.filter(t => t.status === 'CONCILIADA' && t.categoriaId);
|
||||
const naoCategorizadas = extrato.filter(t => t.status === 'PENDENTE' || !t.categoriaId);
|
||||
const conciliadas = extrato.filter(t => t?.status === 'CONCILIADA' && t?.categoriaId);
|
||||
const naoCategorizadas = extrato.filter(t => t?.status === 'PENDENTE' || !t?.categoriaId);
|
||||
|
||||
setTransacoesConciliadas(conciliadas);
|
||||
setTransacoesNaoCategorizadas(naoCategorizadas);
|
||||
|
|
@ -144,7 +145,7 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
|
||||
if (nivel === 0) {
|
||||
// Nível 0: Caixas (Agora usando a rota detalhada para cada caixa para matar o 'cruzamentos')
|
||||
const sourceCaixas = filters.caixas || caixas;
|
||||
const sourceCaixas = (filters && filters.caixas) || caixas || [];
|
||||
|
||||
// Buscar totais para cada caixa usando a rota detalhada em paralelo
|
||||
console.log('[useConciliacaoV2] Buscando totais detalhados para todas as caixas (Substituindo cruzamentos)...');
|
||||
|
|
@ -162,8 +163,8 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
|
||||
return {
|
||||
...c,
|
||||
totalTransacoes: detailed.resumo?.total_transacoes || calcTransacoes,
|
||||
totalValor: detailed.resumo?.valor_total || calcValor,
|
||||
totalTransacoes: detailed.resumo?.total_transacoes || detailed.total_transacoes || calcTransacoes,
|
||||
totalValor: detailed.valor_total || detailed.resumo?.valor_total || calcValor,
|
||||
tipo: 'caixa'
|
||||
};
|
||||
} catch (e) {
|
||||
|
|
@ -175,45 +176,58 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
|
||||
} else if (nivel === 1) {
|
||||
// Nível 1: Categorias (Vindo da API Detalhada da Caixinha)
|
||||
const caixinhaId = filters.caixinha?.id || filters.caixa?.id || caixaSelecionado?.id;
|
||||
if (!caixinhaId) throw new Error('Caixinha não selecionada para nível 1');
|
||||
// Se filters for um objeto com id (o item clicado), usamos ele. Senão, tentamos pegar do estado ou do objeto filters
|
||||
const caixinhaId = filters?.id || filters?.caixinha?.id || filters?.caixa?.id || caixaSelecionado?.id;
|
||||
|
||||
if (!caixinhaId) {
|
||||
console.error('[useConciliacaoV2] Falha ao identificar ID da caixinha:', { filters, caixaSelecionado });
|
||||
throw new Error('Caixinha não selecionada para nível 1');
|
||||
}
|
||||
|
||||
console.log('[useConciliacaoV2] >>> ACESSANDO NOVA ROTA HIERÁRQUICA DO BACKEND <<<');
|
||||
console.log(`[useConciliacaoV2] Rota: /extrato/apresentar/caixinha/detalhado?caixinha=${caixinhaId}&mes=${filtroMes}&ano=${filtroAno}`);
|
||||
const detailedData = await workspaceConciliacaoService.fetchExtratoDetalhado(caixinhaId, { mes: filtroMes, ano: filtroAno });
|
||||
|
||||
data = (detailedData.categorias || []).map(cat => {
|
||||
// Calcular sub-totais de transações se não vier
|
||||
const subTransacoes = cat.total_transacoes || (cat.beneficiarios || []).reduce((acc, ben) => acc + (ben.total_transacoes || 0), 0);
|
||||
// Preparamos as transações achatadas por precaução, mas agora usaremos subgrupos se existirem
|
||||
const catTransacoes = cat.subgrupos
|
||||
? cat.subgrupos.flatMap(sub => sub.transacoes || [])
|
||||
: (cat.transacoes || []);
|
||||
|
||||
const totalTransacoes = cat.total_transacoes || catTransacoes.length;
|
||||
|
||||
return {
|
||||
id: `cat_${cat.categoria}`,
|
||||
nome: cat.categoria || 'Sem Categoria',
|
||||
totalTransacoes: subTransacoes,
|
||||
totalTransacoes: totalTransacoes,
|
||||
totalValor: cat.valor_total || 0,
|
||||
tipo: 'categoria',
|
||||
beneficiarios: cat.beneficiarios || [],
|
||||
subgrupos: cat.subgrupos || [],
|
||||
transacoes: catTransacoes,
|
||||
cor: categorias.find(c => c.nome === cat.categoria)?.cor || '#3b82f6'
|
||||
};
|
||||
});
|
||||
|
||||
} else if (nivel === 2) {
|
||||
// Nível 2: Beneficiários (Dados já estão no item selecionado)
|
||||
const categoriaItem = filters.categoria || categoriaSelecionada;
|
||||
data = (categoriaItem?.beneficiarios || []).map(ben => ({
|
||||
id: `ben_${ben.beneficiario}`,
|
||||
nome: ben.beneficiario || 'Sem Beneficiário',
|
||||
beneficiario: ben.beneficiario,
|
||||
totalTransacoes: ben.total_transacoes || 0,
|
||||
totalValor: ben.valor_total || 0,
|
||||
tipo: 'regra',
|
||||
transacoes: ben.transacoes || []
|
||||
// Nível 2: Subgrupos (Vindo da categoria selecionada)
|
||||
// Se o filtro for a própria categoria (passada diretamente), usamos ela. Senão, pegamos do estado.
|
||||
const categoriaItem = (filters?.subgrupos ? filters : filters?.categoria) || categoriaSelecionada;
|
||||
const rawSubgrupos = categoriaItem?.subgrupos || [];
|
||||
|
||||
data = rawSubgrupos.map((sub, idx) => ({
|
||||
id: `sub_${sub.nome_agrupamento}_${idx}`,
|
||||
nome: sub.nome_agrupamento || 'Sem Identificação',
|
||||
totalTransacoes: sub.total_transacoes || (sub.transacoes?.length || 0),
|
||||
totalValor: sub.valor_total || 0,
|
||||
tipo: 'subgrupo',
|
||||
transacoes: sub.transacoes || [],
|
||||
cor: '#94a3b8' // Slate 400
|
||||
}));
|
||||
|
||||
} else if (nivel === 3) {
|
||||
// Nível 3: Transações (Normalizar dados para garantir compatibilidade com gráficos/tabelas)
|
||||
const detalheItem = filters.regra || detalheSelecionado;
|
||||
const rawTransacoes = detalheItem?.transacoes || [];
|
||||
// Nível 3: Transações (Vindo do item selecionado - pode ser categoria ou subgrupo)
|
||||
const itemPai = (filters?.transacoes ? filters : (filters?.subgrupo || subgroupSelecionado || filters?.categoria || categoriaSelecionada));
|
||||
const rawTransacoes = itemPai?.transacoes || [];
|
||||
|
||||
data = rawTransacoes.map(t => ({
|
||||
...t,
|
||||
|
|
@ -229,6 +243,7 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
return data;
|
||||
} catch (error) {
|
||||
console.error('[useConciliacaoV2] Erro ao buscar dados de navegação:', error);
|
||||
setBackendNavData([]); // Limpar dados em caso de erro para evitar confusão visual
|
||||
toast.error('Erro ao navegar nos dados', 'Erro');
|
||||
return [];
|
||||
} finally {
|
||||
|
|
@ -240,50 +255,54 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
const navegarPara = async (tipo, item) => {
|
||||
console.log('[useConciliacaoV2] Navegando para:', tipo, item);
|
||||
|
||||
// Limpa dados atuais para mostrar o loading logo
|
||||
setBackendNavData([]);
|
||||
|
||||
let novoNivel = 0;
|
||||
if (tipo === 'caixa') {
|
||||
setCaixaSelecionado(item);
|
||||
setCategoriaSelecionada(null);
|
||||
setDetalheSelecionado(null);
|
||||
setSubgroupSelecionado(null);
|
||||
novoNivel = 1;
|
||||
setCaminhoNavegacao([{ tipo: 'caixa', item }]);
|
||||
} else if (tipo === 'categoria') {
|
||||
setCategoriaSelecionada(item);
|
||||
setDetalheSelecionado(null);
|
||||
setSubgroupSelecionado(null);
|
||||
novoNivel = 2;
|
||||
setCaminhoNavegacao(prev => [...prev, { tipo: 'categoria', item }]);
|
||||
} else if (tipo === 'regra') {
|
||||
setDetalheSelecionado(item);
|
||||
} else if (tipo === 'subgrupo') {
|
||||
setSubgroupSelecionado(item);
|
||||
novoNivel = 3;
|
||||
setCaminhoNavegacao(prev => [...prev, { tipo: 'regra', item }]);
|
||||
setCaminhoNavegacao(prev => [...prev, { tipo: 'subgrupo', item }]);
|
||||
}
|
||||
|
||||
setNivelNavegacao(novoNivel);
|
||||
await fetchNivelNavegacao(novoNivel, { [tipo]: item });
|
||||
await fetchNivelNavegacao(novoNivel, item);
|
||||
};
|
||||
|
||||
const voltarNavegacao = async () => {
|
||||
if (nivelNavegacao === 0) return;
|
||||
|
||||
const novoNivel = nivelNavegacao - 1;
|
||||
|
||||
// Determina qual item passar como contexto para o filtro
|
||||
let itemContexto = null;
|
||||
if (novoNivel === 2) itemContexto = categoriaSelecionada;
|
||||
if (novoNivel === 1) itemContexto = caixaSelecionado;
|
||||
|
||||
await fetchNivelNavegacao(novoNivel, itemContexto);
|
||||
|
||||
if (nivelNavegacao === 3) setSubgroupSelecionado(null);
|
||||
if (nivelNavegacao === 2) setCategoriaSelecionada(null);
|
||||
if (nivelNavegacao === 1) setCaixaSelecionado(null);
|
||||
|
||||
setNivelNavegacao(novoNivel);
|
||||
setCaminhoNavegacao(prev => prev.slice(0, novoNivel));
|
||||
|
||||
if (novoNivel === 0) {
|
||||
setCaixaSelecionado(null);
|
||||
setCategoriaSelecionada(null);
|
||||
setDetalheSelecionado(null);
|
||||
await fetchNivelNavegacao(0);
|
||||
} else if (novoNivel === 1) {
|
||||
setCategoriaSelecionada(null);
|
||||
setDetalheSelecionado(null);
|
||||
await fetchNivelNavegacao(1, { caixinha: caixaSelecionado });
|
||||
} else if (novoNivel === 2) {
|
||||
setDetalheSelecionado(null);
|
||||
await fetchNivelNavegacao(2, { categoria: categoriaSelecionada });
|
||||
}
|
||||
};
|
||||
|
||||
const navegarParaFrente = navegarPara;
|
||||
const navegarParaTras = voltarNavegacao;
|
||||
|
||||
// Carregar dados iniciais do backend
|
||||
useEffect(() => {
|
||||
console.log('[useConciliacaoV2] useEffect executado - montando componente');
|
||||
|
|
@ -381,10 +400,10 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
// Buscar transações apenas se for necessário ou se estiver na aba correta (Lazy Loading)
|
||||
if (initialSubView === 'extrato-completo' || initialSubView === 'nao-categorizadas') {
|
||||
console.log('[useConciliacaoV2] Buscando extrato completo para visualização específica...');
|
||||
const extrato = await workspaceConciliacaoService.fetchExtrato();
|
||||
setExtratoCompleto(Array.isArray(extrato) ? extrato : []);
|
||||
const conciliadas = extrato.filter(t => t.status === 'CONCILIADA' && t.categoriaId);
|
||||
const naoCategorizadas = extrato.filter(t => t.status === 'PENDENTE' || !t.categoriaId);
|
||||
const extrato = (await workspaceConciliacaoService.fetchExtrato({ mes: filtroMes, ano: filtroAno })) || [];
|
||||
setExtratoCompleto(extrato);
|
||||
const conciliadas = extrato.filter(t => t?.status === 'CONCILIADA' && t?.categoriaId);
|
||||
const naoCategorizadas = extrato.filter(t => t?.status === 'PENDENTE' || !t?.categoriaId);
|
||||
setTransacoesConciliadas(conciliadas);
|
||||
setTransacoesNaoCategorizadas(naoCategorizadas);
|
||||
}
|
||||
|
|
@ -439,12 +458,12 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
if (!isLoading && hasInitialLoad) {
|
||||
const loadExtrato = async () => {
|
||||
try {
|
||||
console.log('[useConciliacaoV2] Recarregando extrato devido a mudança de filtros...');
|
||||
const extrato = await workspaceConciliacaoService.fetchExtrato();
|
||||
setExtratoCompleto(Array.isArray(extrato) ? extrato : []);
|
||||
console.log(`[useConciliacaoV2] Recarregando extrato devido a mudança de ${activeSubView === 'nao-categorizadas' ? 'aba' : 'filtros'}...`);
|
||||
const extrato = (await workspaceConciliacaoService.fetchExtrato({ mes: filtroMes, ano: filtroAno })) || [];
|
||||
setExtratoCompleto(extrato);
|
||||
// Separar transações conciliadas e não categorizadas
|
||||
const conciliadas = extrato.filter(t => t.status === 'CONCILIADA' && t.categoriaId);
|
||||
const naoCategorizadas = extrato.filter(t => t.status === 'PENDENTE' || !t.categoriaId);
|
||||
const conciliadas = extrato.filter(t => t?.status === 'CONCILIADA' && t?.categoriaId);
|
||||
const naoCategorizadas = extrato.filter(t => t?.status === 'PENDENTE' || !t?.categoriaId);
|
||||
|
||||
setTransacoesConciliadas(conciliadas);
|
||||
setTransacoesNaoCategorizadas(naoCategorizadas);
|
||||
|
|
@ -457,7 +476,7 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
} else if (!isLoading && !hasInitialLoad) {
|
||||
setHasInitialLoad(true);
|
||||
}
|
||||
}, [filtroAno, filtroMes, isLoading, hasInitialLoad]);
|
||||
}, [filtroAno, filtroMes, activeSubView, isLoading, hasInitialLoad]);
|
||||
|
||||
// Transações filtradas e organizadas (MANTIDO APENAS SE NECESSÁRIO PARA COMPATIBILIDADE, MAS AGORA REDUNDANTE)
|
||||
const transacoesOrganizadas = useMemo(() => {
|
||||
|
|
@ -842,6 +861,7 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
regra: '0',
|
||||
beneficiario_pagador: dadosCategorizacao.beneficiario || '',
|
||||
caixinha: String(dadosCategorizacao.caixaId || '0'),
|
||||
tag: dadosCategorizacao.tag || '',
|
||||
Resutl: true
|
||||
};
|
||||
|
||||
|
|
@ -858,7 +878,8 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
url: '/caixinha_extrato/inserir',
|
||||
method: 'UPDATE',
|
||||
data: objeto
|
||||
})
|
||||
}),
|
||||
dadosCategorizacao.tag ? api.post('/tag/inserir', { idextrato: transacaoId, tag: dadosCategorizacao.tag }) : Promise.resolve()
|
||||
]);
|
||||
|
||||
// Recarregar extrato do backend
|
||||
|
|
@ -939,7 +960,7 @@ export function useConciliacaoV2(defaultView = 'conciliadas') {
|
|||
caminhoNavegacao,
|
||||
caixaSelecionado,
|
||||
categoriaSelecionada,
|
||||
detalheSelecionado,
|
||||
subgroupSelecionado,
|
||||
isLoading,
|
||||
isNavLoading,
|
||||
backendNavData,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const useContasPagar = () => {
|
|||
const [activeSubView, setActiveSubView] = useState('default'); // 'default' | 'fornecedores' | 'despesas' | 'cruzamento'
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
|
||||
// Filtros de busca (Mês/Ano)
|
||||
const [filtroMes, setFiltroMes] = useState(String(new Date().getMonth() + 1));
|
||||
const [filtroAno, setFiltroAno] = useState(String(new Date().getFullYear()));
|
||||
const [filtroTipoPeriodo, setFiltroTipoPeriodo] = useState('mes'); // 'mes' | 'ano' | 'todos'
|
||||
const [filtroTipoCobranca, setFiltroTipoCobranca] = useState('todas'); // 'todas' | 'Recorrente' | 'Avulsa'
|
||||
|
||||
// Estados para Fornecedores
|
||||
const [fornecedores, setFornecedores] = useState([]);
|
||||
|
|
@ -59,7 +65,19 @@ export const useContasPagar = () => {
|
|||
const fetchDespesas = useCallback(async () => {
|
||||
setDespesasLoading(true);
|
||||
try {
|
||||
const data = await workspaceDespesasV2Service.fetchDespesas();
|
||||
const filters = {};
|
||||
if (filtroTipoPeriodo === 'mes') {
|
||||
filters.mes = filtroMes;
|
||||
filters.ano = filtroAno;
|
||||
} else if (filtroTipoPeriodo === 'ano') {
|
||||
filters.ano = filtroAno;
|
||||
}
|
||||
|
||||
if (filtroTipoCobranca !== 'todas') {
|
||||
filters.tipo_cobranca = filtroTipoCobranca;
|
||||
}
|
||||
|
||||
const data = await workspaceDespesasV2Service.fetchDespesas(filters);
|
||||
const list = data || [];
|
||||
setDespesas(list);
|
||||
|
||||
|
|
@ -81,7 +99,7 @@ export const useContasPagar = () => {
|
|||
} finally {
|
||||
setDespesasLoading(false);
|
||||
}
|
||||
}, [activeSubView]);
|
||||
}, [activeSubView, filtroMes, filtroAno, filtroTipoPeriodo, filtroTipoCobranca]);
|
||||
|
||||
// Dispara a busca quando a sub-view muda ou na montagem
|
||||
useEffect(() => {
|
||||
|
|
@ -218,8 +236,8 @@ export const useContasPagar = () => {
|
|||
|
||||
// Ações para Despesas V2 - Memoizadas
|
||||
const createDespesa = useCallback(async (despesaData) => {
|
||||
if (!despesaData.data || !despesaData.contaDespesa || !despesaData.montante || !despesaData.status) {
|
||||
toastRef.current.error('Por favor, preencha os campos obrigatórios');
|
||||
if (!despesaData.montante || !despesaData.categoria) {
|
||||
toastRef.current.error('Por favor, preencha os campos obrigatórios (Montante e Categoria)');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -243,6 +261,13 @@ export const useContasPagar = () => {
|
|||
const result = await workspaceDespesasV2Service.updateDespesa(payload);
|
||||
if (result) {
|
||||
toastRef.current.success('Despesa atualizada com sucesso!');
|
||||
|
||||
// Atualiza o estado da despesa selecionada para refletir as mudanças no painel lateral
|
||||
setSelectedDespesa(prev => (prev && (prev.idDespesa === id || prev.id === id))
|
||||
? { ...prev, ...despesaData }
|
||||
: prev
|
||||
);
|
||||
|
||||
fetchDespesas();
|
||||
return result;
|
||||
}
|
||||
|
|
@ -337,9 +362,14 @@ export const useContasPagar = () => {
|
|||
cruzamentoLoading,
|
||||
categorias,
|
||||
caixinhas,
|
||||
kpisCruzamento
|
||||
kpisCruzamento,
|
||||
// Filtros expostos
|
||||
filtroMes,
|
||||
filtroAno,
|
||||
filtroTipoPeriodo,
|
||||
filtroTipoCobranca
|
||||
},
|
||||
actions: {
|
||||
actions: useMemo(() => ({
|
||||
setActiveTab,
|
||||
setActiveSubView,
|
||||
setSearchTerm,
|
||||
|
|
@ -358,7 +388,18 @@ export const useContasPagar = () => {
|
|||
deleteItemDespesa,
|
||||
// Cruzamento
|
||||
setDespesasPlanejadas,
|
||||
setDespesasExecutadas
|
||||
}
|
||||
setDespesasExecutadas,
|
||||
// Setters de filtros
|
||||
setFiltroMes,
|
||||
setFiltroAno,
|
||||
setFiltroTipoPeriodo,
|
||||
setFiltroTipoCobranca
|
||||
}), [
|
||||
setActiveTab, setActiveSubView, setSearchTerm, fetchDespesas, fetchFornecedores,
|
||||
setSelectedFornecedor, createDespesa, updateDespesa, deleteDespesa,
|
||||
setSelectedDespesa, createItemDespesa, updateItemDespesa, deleteItemDespesa,
|
||||
setDespesasPlanejadas, setDespesasExecutadas, setFiltroMes, setFiltroAno,
|
||||
setFiltroTipoPeriodo, setFiltroTipoCobranca
|
||||
])
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { formatDateForChart } from '../utils/dateUtils';
|
|||
import { boletosService } from '@/services/boletosService';
|
||||
import { workspaceDespesasService } from '@/services/workspaceDespesasService';
|
||||
import { workspaceSaldoService } from '@/services/workspaceSaldoService';
|
||||
import { parseDateInfo } from '@/utils/dateUtils';
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -47,12 +48,20 @@ export const useDashboard = () => {
|
|||
});
|
||||
|
||||
// Função auxiliar para formatar data no formato "YYYY-MM"
|
||||
const formatMesAno = (date) => {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const ano = d.getFullYear();
|
||||
const mes = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const formatMesAno = (dateOrStr) => {
|
||||
if (!dateOrStr) return '';
|
||||
let ano, mes;
|
||||
if (typeof dateOrStr === 'string' && dateOrStr.trim().length > 0) {
|
||||
const { year, month } = parseDateInfo(dateOrStr);
|
||||
if (!year) return '';
|
||||
ano = year;
|
||||
mes = String(month).padStart(2, '0');
|
||||
} else {
|
||||
const d = dateOrStr instanceof Date ? dateOrStr : new Date(dateOrStr);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
ano = d.getFullYear();
|
||||
mes = String(d.getMonth() + 1).padStart(2, '0');
|
||||
}
|
||||
return `${ano}-${mes}`;
|
||||
};
|
||||
|
||||
|
|
@ -142,13 +151,10 @@ export const useDashboard = () => {
|
|||
let transacoesPendentesCnt = 0;
|
||||
|
||||
extrato.forEach((item) => {
|
||||
const itemMes = formatMesAno(new Date(item.dataEntrada));
|
||||
const itemMes = formatMesAno(item.dataEntrada);
|
||||
|
||||
// Pendentes de conciliação (Contagem global ou mensal?
|
||||
// Geralmente pendências são globais, mas para bater com "Transações do Mês" talvez filtrar.
|
||||
// O usuário pediu "entradas e saídas do mês vigente". Pendências vou manter geral ou filtrar?
|
||||
// Vou filtrar apenas as SOMAS financeiras pelo mês atual conforme solicitado.
|
||||
if (!item.categoria || item.categoria == 0) {
|
||||
// Pendentes de conciliação do Mês
|
||||
if ((!item.categoria || item.categoria == 0) && itemMes === mesAtualStr) {
|
||||
transacoesPendentesCnt++;
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +209,7 @@ export const useDashboard = () => {
|
|||
const cobranca = item.cobranca || item;
|
||||
if (!cobranca.dataVencimento) return;
|
||||
|
||||
const vencMes = formatMesAno(new Date(cobranca.dataVencimento));
|
||||
const vencMes = formatMesAno(cobranca.dataVencimento);
|
||||
if (vencMes === mesAtualStr && statusesPendentes.includes(cobranca.situacao)) {
|
||||
qtdPendencia++;
|
||||
const valTotal = safeNumber(cobranca.valorTotalRecebido);
|
||||
|
|
@ -292,7 +298,7 @@ export const useDashboard = () => {
|
|||
|
||||
data.boletos.cobrancas?.forEach((item) => {
|
||||
const c = item.cobranca || item;
|
||||
if (formatMesAno(new Date(c.dataVencimento)) !== mesAtual) return;
|
||||
if (formatMesAno(c.dataVencimento) !== mesAtual) return;
|
||||
|
||||
const st = c.situacao || 'OUTROS';
|
||||
const valor = safeNumber(c.valorNominal);
|
||||
|
|
@ -301,7 +307,7 @@ export const useDashboard = () => {
|
|||
});
|
||||
|
||||
const colors = {
|
||||
'PAGO': '#10b981', 'RECEBIDO': '#10b981', 'MARCADO_RECEBIDO': '#10b981',
|
||||
'PAGO': '#10b981', 'RECEBIDO': '#10b981', 'MARCADO_RECEBIDO': '#f59e0b',
|
||||
'A_RECEBER': '#3b82f6',
|
||||
'ATRASADO': '#f43f5e',
|
||||
'CANCELADO': '#64748b',
|
||||
|
|
@ -381,14 +387,20 @@ export const useDashboard = () => {
|
|||
return [...data.extrato]
|
||||
.filter(item => {
|
||||
if (!item.dataEntrada) return false;
|
||||
const dataItem = new Date(item.dataEntrada);
|
||||
// Remove hora para comparação justa de data
|
||||
const dataItemSemHora = new Date(dataItem);
|
||||
|
||||
const { day, month, year } = parseDateInfo(item.dataEntrada);
|
||||
if (!year) return false;
|
||||
|
||||
const dataItemSemHora = new Date(year, month - 1, day);
|
||||
dataItemSemHora.setHours(0, 0, 0, 0);
|
||||
|
||||
return dataItemSemHora >= dataLimite && dataItemSemHora <= hoje;
|
||||
})
|
||||
.sort((a, b) => new Date(b.dataEntrada) - new Date(a.dataEntrada))
|
||||
.sort((a, b) => {
|
||||
const pA = parseDateInfo(a.dataEntrada);
|
||||
const pB = parseDateInfo(b.dataEntrada);
|
||||
return new Date(pB.year, pB.month - 1, pB.day) - new Date(pA.year, pA.month - 1, pA.day);
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map(item => {
|
||||
// Enriquecer com nome da categoria
|
||||
|
|
@ -417,8 +429,8 @@ export const useDashboard = () => {
|
|||
const allExtrato = await extratoService.fetchExtrato();
|
||||
return allExtrato.filter(item => {
|
||||
if (!item.dataEntrada) return false;
|
||||
const d = new Date(item.dataEntrada);
|
||||
return (d.getMonth() + 1) === mes && d.getFullYear() === ano && item.tipoOperacao === 'C';
|
||||
const { month: m, year: y } = parseDateInfo(item.dataEntrada);
|
||||
return m === mes && y === ano && item.tipoOperacao === 'C';
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching entradas mes from extrato:', err);
|
||||
|
|
@ -430,17 +442,17 @@ export const useDashboard = () => {
|
|||
const allExtrato = await extratoService.fetchExtrato();
|
||||
return allExtrato.filter(item => {
|
||||
if (!item.dataEntrada) return false;
|
||||
const d = new Date(item.dataEntrada);
|
||||
return (d.getMonth() + 1) === mes && d.getFullYear() === ano && item.tipoOperacao === 'D';
|
||||
const { month: m, year: y } = parseDateInfo(item.dataEntrada);
|
||||
return m === mes && y === ano && item.tipoOperacao === 'D';
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching saidas periodo from extrato:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
fetchTransacoesNaoConciliadas: async () => {
|
||||
fetchTransacoesNaoConciliadas: async (mes, ano) => {
|
||||
try {
|
||||
const allExtrato = await extratoService.fetchExtrato();
|
||||
const allExtrato = await extratoService.fetchExtrato({ mes, ano });
|
||||
return allExtrato.filter(item => !item.categoria || item.categoria == 0);
|
||||
} catch (err) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -79,7 +79,13 @@ export function useFluxoCaixa() {
|
|||
let d;
|
||||
if (typeof itemDate === 'string') {
|
||||
if (itemDate.includes('/')) {
|
||||
const [day, month, year] = itemDate.split('/');
|
||||
const parts = itemDate.split('/');
|
||||
const day = parts[0];
|
||||
const month = parts[1];
|
||||
// Handle if there's a time after the year: "2026 11:30"
|
||||
const yearPart = parts[2] || '';
|
||||
const year = yearPart.split(' ')[0];
|
||||
|
||||
d = new Date(year, month - 1, day);
|
||||
} else {
|
||||
d = new Date(itemDate);
|
||||
|
|
@ -88,7 +94,7 @@ export function useFluxoCaixa() {
|
|||
d = new Date(itemDate);
|
||||
}
|
||||
|
||||
if (Number.isNaN(d.getTime())) return true;
|
||||
if (!d || Number.isNaN(d.getTime())) return false;
|
||||
|
||||
const itemMonth = String(d.getMonth() + 1);
|
||||
const itemYear = String(d.getFullYear());
|
||||
|
|
|
|||
|
|
@ -172,3 +172,59 @@ export const formatDateForChart = (dateString) => {
|
|||
return '-';
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Formata o status do boleto para exibição amigável
|
||||
* @param {string} status
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formatStatus = (status) => {
|
||||
if (!status) return 'Pendente';
|
||||
const s = String(status).toUpperCase();
|
||||
const map = {
|
||||
'A_RECEBER': 'A Receber',
|
||||
'PENDENTE': 'Pendente',
|
||||
'ATRASADO': 'Atrasado',
|
||||
'VENCIDO': 'Vencido',
|
||||
'PAGO': 'Pago',
|
||||
'RECEBIDO': 'Recebido',
|
||||
'CANCELADO': 'Cancelado',
|
||||
'MARCADO_RECEBIDO': 'Marcado Recebido',
|
||||
'PIX': 'Pix'
|
||||
};
|
||||
return map[s] || status;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converte qualquer formato de data válido para YYYY-MM-DD.
|
||||
* Útil para campos <input type="date" />
|
||||
* @param {string|Date} dateString
|
||||
* @returns {string} YYYY-MM-DD
|
||||
*/
|
||||
export const toISODate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
|
||||
// Se já estiver no formato ISO YYYY-MM-DD, retorna o início da string
|
||||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dateString)) {
|
||||
return dateString.substring(0, 10);
|
||||
}
|
||||
|
||||
// Se estiver no formato brasileiro DD/MM/YYYY
|
||||
if (typeof dateString === 'string' && /^\d{2}\/\d{2}\/\d{4}/.test(dateString)) {
|
||||
const [day, month, year] = dateString.split('/');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
// Usamos UTC para evitar problemas de fuso horário vindo do backend
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
import { useDashboard } from '../hooks/useDashboard';
|
||||
import { TransactionDetailModal } from '../components/TransactionDetailModal';
|
||||
import { formatDate, formatCurrency } from '../utils/dateUtils';
|
||||
import { parseDateInfo } from '@/utils/dateUtils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -63,7 +64,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
const MESES_REC = ['', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
|
||||
const ANOS_REC = Array.from({ length: 11 }, (_, i) => new Date().getFullYear() - 5 + i);
|
||||
|
||||
// Componente de card de resumo premium
|
||||
// Premium KPI Card with Glow and Glassmorphism - Dashboard Version
|
||||
const XPICard = ({
|
||||
title,
|
||||
value,
|
||||
|
|
@ -82,49 +83,57 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
className={cn(
|
||||
"bg-white dark:bg-[#0f172a] border border-slate-200 dark:border-slate-800/50 shadow-xl relative overflow-hidden group cursor-pointer transition-all duration-300 hover:border-emerald-500/30 hover:bg-slate-50 dark:hover:bg-[#111a2e] rounded-xl",
|
||||
onClick && "active:scale-[0.98]"
|
||||
"bg-white dark:bg-[#1e293b]/40 backdrop-blur-md border-slate-200 dark:border-slate-800 shadow-xl relative overflow-hidden group transition-all duration-500 hover:border-slate-300 dark:hover:border-slate-700/50 rounded-2xl",
|
||||
onClick && "cursor-pointer active:scale-[0.98]"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="p-5 relative z-10">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={cn("w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-500 group-hover:shadow-[0_0_20px_rgba(16,185,129,0.2)]", bgIcon)}>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Icon size={18} />
|
||||
{/* Animated Glow Effect */}
|
||||
<div className={cn(
|
||||
"absolute -right-12 -bottom-12 w-[clamp(8rem,15vw,12rem)] h-[clamp(8rem,15vw,12rem)] rounded-full opacity-0 group-hover:opacity-10 transition-opacity duration-700 blur-3xl bg-emerald-500"
|
||||
)} />
|
||||
|
||||
<CardContent className="p-[clamp(0.875rem,1.5vw,1.375rem)] flex items-start justify-between relative z-10">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[clamp(0.625rem,0.7vw,0.75rem)] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] mb-[clamp(0.375rem,0.5vw,0.625rem)]">{title}</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-[clamp(1.25rem,2vw,1.75rem)] w-36 bg-slate-200 dark:bg-slate-700 animate-pulse rounded mt-1" />
|
||||
) : (
|
||||
<h3 className="text-[clamp(1.25rem,2vw,1.75rem)] font-bold tracking-tight text-slate-900 dark:text-white leading-none">
|
||||
{value}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-[clamp(0.375rem,0.5vw,0.75rem)] mt-[clamp(0.5rem,1vw,1rem)] flex-wrap">
|
||||
{trend && !loading && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 text-[clamp(0.7rem,0.8vw,0.85rem)] font-bold px-[clamp(0.5rem,0.6vw,0.75rem)] py-1 rounded-lg shrink-0",
|
||||
trend > 0 ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-rose-500/10 text-rose-600 dark:text-rose-400"
|
||||
)}>
|
||||
{trend > 0 ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
|
||||
{Math.abs(trend)}%
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[clamp(0.7rem,0.8vw,0.8rem)] text-slate-500 dark:text-slate-400 font-bold leading-tight truncate opacity-80">{subtitle}</p>
|
||||
</div>
|
||||
{trend && !loading && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full",
|
||||
trend > 0 ? "bg-emerald-500/10 text-emerald-500" : "bg-rose-400/10 text-rose-400"
|
||||
)}>
|
||||
{trend > 0 ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
|
||||
{Math.abs(trend)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"w-[clamp(2.5rem,3.5vw,3.25rem)] h-[clamp(2.5rem,3.5vw,3.25rem)] rounded-2xl flex items-center justify-center transition-all duration-500 group-hover:scale-110 shadow-2xl shrink-0",
|
||||
"bg-opacity-10 border border-current border-opacity-20",
|
||||
bgIcon.replace('bg-', 'text-').replace('/10', '')
|
||||
)} style={{ backgroundColor: bgIcon.includes('/') ? undefined : 'currentColor' }}>
|
||||
{loading ? (
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
) : (
|
||||
<Icon size={20} className={bgIcon.replace('bg-', 'text-').split(' ')[1] || bgIcon.replace('bg-', 'text-')} strokeWidth={2.5} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-500 uppercase tracking-[0.1em]">{title}</span>
|
||||
<div className="text-xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||
{loading ? (
|
||||
<div className="h-7 w-24 bg-slate-200 dark:bg-slate-800 animate-pulse rounded" />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-400 font-medium truncate">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle Gradient Shadow */}
|
||||
<div className="absolute -bottom-6 -right-6 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-all duration-500 pointer-events-none" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -206,7 +215,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
data = await fetchSaidasPeriodo(mes, ano);
|
||||
break;
|
||||
case 'naoConciliadas':
|
||||
data = await fetchTransacoesNaoConciliadas(); // Usually global, but could filter if APIs allowed
|
||||
data = await fetchTransacoesNaoConciliadas(mes, ano);
|
||||
break;
|
||||
case 'boletosAbertos':
|
||||
data = await fetchBoletosAbertos(); // Global
|
||||
|
|
@ -310,7 +319,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
</div>
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<XPICard
|
||||
title="SALDO EM CONTA"
|
||||
value={formatCurrency(resumo.saldoTotal)}
|
||||
|
|
@ -411,32 +420,30 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={300}>
|
||||
<BarChart data={getJurosData()} margin={{ top: 20, right: 30, left: 10, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#64748b" opacity={0.2} />
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={getJurosData()} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#64748b" opacity={0.1} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{fill: '#64748b', fontSize: 10, fontWeight: 700}}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{fill: '#64748b', fontSize: 10}}
|
||||
tickFormatter={(val) => `R$ ${(val/1000).toFixed(0)}k`}
|
||||
dx={-5}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0,0,0,0.05)', opacity: 0.1 }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.05)' }}
|
||||
content={<FinanceiroChartTooltip formatCurrency={formatCurrency} />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="valor"
|
||||
fill="#3b82f6"
|
||||
radius={[4, 4, 0, 0]}
|
||||
barSize={40}
|
||||
radius={[6, 6, 0, 0]}
|
||||
barSize={50}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
@ -601,7 +608,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
{t.tipoTransacao || 'GERAL'}
|
||||
</span>
|
||||
<span className="text-[clamp(0.7rem,1.1vw,0.8125rem)] text-slate-400">
|
||||
{formatDate(t.dataEntrada)}
|
||||
{t.dataEntrada}
|
||||
</span>
|
||||
</div>
|
||||
{(t.beneficiario_pagador) && (
|
||||
|
|
@ -653,13 +660,15 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
onViewInExtrato={(transaction) => {
|
||||
if (transaction.tipoOperacao === 'C') {
|
||||
// Open Entradas modal
|
||||
const date = new Date(transaction.dataEntrada);
|
||||
const parsed = parseDateInfo(transaction.dataEntrada);
|
||||
const date = new Date(parsed.year || new Date().getFullYear(), (parsed.month || new Date().getMonth() + 1) - 1, parsed.day || 1);
|
||||
setModalDate(date);
|
||||
handleModalDateChange('saldo', date);
|
||||
setModalState(prev => ({ ...prev, saldo: true }));
|
||||
} else {
|
||||
// Open Saídas modal
|
||||
const date = new Date(transaction.dataEntrada);
|
||||
const parsed = parseDateInfo(transaction.dataEntrada);
|
||||
const date = new Date(parsed.year || new Date().getFullYear(), (parsed.month || new Date().getMonth() + 1) - 1, parsed.day || 1);
|
||||
setModalDate(date);
|
||||
handleModalDateChange('despesas', date);
|
||||
setModalState(prev => ({ ...prev, despesas: true }));
|
||||
|
|
@ -715,7 +724,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
)
|
||||
},
|
||||
{ header: 'Data', field: 'dataEntrada', width: 120, render: (row) => (
|
||||
<span className="text-slate-500 font-medium">{formatDate(row.dataEntrada)}</span>
|
||||
<span className="text-slate-500 font-medium">{row.dataEntrada}</span>
|
||||
)},
|
||||
{ header: 'Descrição', field: 'descricao', width: 250, render: (row) => (
|
||||
<span className="font-bold text-slate-900 dark:text-white">{row.descricao || 'Sem descrição'}</span>
|
||||
|
|
@ -788,7 +797,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
)
|
||||
},
|
||||
{ header: 'Data', field: 'dataEntrada', width: 120, render: (row) => (
|
||||
<span className="text-slate-500 font-medium">{formatDate(row.dataEntrada)}</span>
|
||||
<span className="text-slate-500 font-medium">{row.dataEntrada}</span>
|
||||
)},
|
||||
{ header: 'Favorecido', field: 'beneficiario_pagador', width: 200, render: (row) => (
|
||||
<span className="font-bold text-slate-900 dark:text-white">{row.beneficiario_pagador || 'N/A'}</span>
|
||||
|
|
@ -819,14 +828,32 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
|
|||
{/* Modal - Transações Não Conciliadas */}
|
||||
<Dialog open={modalState.naoConciliadas} onOpenChange={(open) => setModalState(prev => ({ ...prev, naoConciliadas: open }))}>
|
||||
<DialogContent className="max-w-5xl bg-white dark:bg-[#0f172a] border-slate-200 dark:border-slate-800 z-[9999] flex flex-col max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<AlertCircle className="text-amber-500" />
|
||||
Transações Pendentes de Conciliação
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
Movimentações do extrato que ainda não foram categorizadas ou vinculadas a um lançamento.
|
||||
</DialogDescription>
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<AlertCircle className="text-amber-500" />
|
||||
Transações Pendentes de Conciliação
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
Movimentações do extrato que ainda não foram categorizadas ou vinculadas a um lançamento no mês selecionado.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-widest hidden sm:block">Período:</span>
|
||||
<input
|
||||
type="month"
|
||||
className="bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md px-3 py-1 text-sm font-bold text-slate-700 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
value={modalDate.toISOString().slice(0, 7)}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const [y, m] = e.target.value.split('-').map(Number);
|
||||
const newDate = new Date(y, m - 1);
|
||||
setModalDate(newDate);
|
||||
handleModalDateChange('naoConciliadas', newDate);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 h-[clamp(300px,60vh,600px)] overflow-auto custom-scrollbar">
|
||||
<ExcelTable
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
|
|||
import { Filter } from 'lucide-react';
|
||||
import ExcelTable from '../../components/ExcelTable';
|
||||
import { formatDate, formatCurrency } from '../../utils/dateUtils';
|
||||
import { parseDateInfo } from '@/utils/dateUtils';
|
||||
import { StatementRow } from '../../components/StatementRow';
|
||||
import { useStatementRefData } from '../../hooks/useStatementRefData';
|
||||
import { CategorizacaoDialog } from '../../components/CategorizacaoDialog';
|
||||
|
|
@ -67,19 +68,9 @@ export function ExtratoCompletoView({ state, actions }) {
|
|||
const dateRaw = t.dataEntrada || t.data;
|
||||
if (!dateRaw) return false;
|
||||
|
||||
let dateMonth, dateYear;
|
||||
|
||||
if (typeof dateRaw === 'string' && dateRaw.includes('-')) {
|
||||
// YYYY-MM-DD format
|
||||
const parts = dateRaw.split('T')[0].split('-');
|
||||
dateYear = parts[0];
|
||||
dateMonth = parts[1].replace(/^0+/, '');
|
||||
} else {
|
||||
// Fallback to Date object for other formats (like the HTTP/GMT one)
|
||||
const date = new Date(dateRaw);
|
||||
dateMonth = String(date.getMonth() + 1);
|
||||
dateYear = String(date.getFullYear());
|
||||
}
|
||||
const { year, month } = parseDateInfo(dateRaw);
|
||||
const dateMonth = String(month);
|
||||
const dateYear = String(year);
|
||||
|
||||
if (filterMonth !== 'todos' && dateMonth !== filterMonth) return false;
|
||||
if (filterYear !== 'todos' && dateYear !== filterYear) return false;
|
||||
|
|
|
|||
|
|
@ -137,11 +137,18 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
caminhoNavegacao,
|
||||
caixaSelecionado,
|
||||
categoriaSelecionada,
|
||||
detalheSelecionado,
|
||||
subgroupSelecionado,
|
||||
backendNavData,
|
||||
isNavLoading
|
||||
} = state;
|
||||
|
||||
// Função utilitária para truncar nomes longos
|
||||
const truncateLabel = (text, limit = 20) => {
|
||||
if (!text) return '';
|
||||
if (text.length <= limit) return text;
|
||||
return text.substring(0, limit) + '...';
|
||||
};
|
||||
|
||||
// Estado local para exportação
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
|
||||
|
|
@ -215,21 +222,13 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
name: caixa.nome,
|
||||
value: Math.abs(caixa.totalValor || 0)
|
||||
}));
|
||||
} else if (nivelNavegacao === 1) {
|
||||
// Barras: Distribuição por Categoria
|
||||
return dadosNivelAtual.map(cat => ({
|
||||
name: cat.nome || 'Sem Categoria',
|
||||
value: Math.abs(cat.totalValor || 0),
|
||||
transacoes: cat.totalTransacoes || 0,
|
||||
cor: cat.cor || '#3b82f6'
|
||||
}));
|
||||
} else if (nivelNavegacao === 2) {
|
||||
// Barras: Distribuição por Beneficiário
|
||||
return dadosNivelAtual.map(ben => ({
|
||||
name: ben.nome || 'Sem Beneficiário',
|
||||
value: Math.abs(ben.totalValor || 0),
|
||||
transacoes: ben.totalTransacoes || 0,
|
||||
cor: '#8b5cf6'
|
||||
} else if (nivelNavegacao === 1 || nivelNavegacao === 2) {
|
||||
// Barras: Distribuição por Categoria (Nível 1) ou Subgrupo (Nível 2)
|
||||
return dadosNivelAtual.map(item => ({
|
||||
name: item.nome || 'Sem Identificação',
|
||||
value: Math.abs(item.totalValor || 0),
|
||||
transacoes: item.totalTransacoes || 0,
|
||||
cor: item.cor || (nivelNavegacao === 2 ? '#94a3b8' : '#3b82f6')
|
||||
}));
|
||||
} else if (nivelNavegacao === 3) {
|
||||
// Timeline: Transações individuais
|
||||
|
|
@ -308,45 +307,74 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
} else if (nivelNavegacao === 1) {
|
||||
// Gráfico de Barras para Categorias
|
||||
} else if (nivelNavegacao === 1 || nivelNavegacao === 2) {
|
||||
// Gráfico de Barras para Categorias (Vertical) ou Subgrupos (Horizontal)
|
||||
const isSubgroup = nivelNavegacao === 2;
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-[#1e293b]/40 border-slate-200 dark:border-slate-800 shadow-xl rounded-lg overflow-hidden mb-6">
|
||||
<CardHeader className="py-4 px-4 sm:px-6 border-b border-slate-200 dark:border-slate-800/50">
|
||||
<CardTitle className="text-sm font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-blue-500" />
|
||||
Distribuição por Categoria
|
||||
Distribuição por {isSubgroup ? 'Subgrupo' : 'Categoria'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="w-full min-w-[300px] h-[400px] min-h-[300px] max-w-2xl mx-auto">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={dadosGrafico}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-slate-200 dark:stroke-slate-700" opacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="fill-slate-600 dark:fill-slate-400"
|
||||
tick={{fontSize: 11, fontWeight: 'bold'}}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="fill-slate-600 dark:fill-slate-400"
|
||||
tick={{fontSize: 10}}
|
||||
tickFormatter={(val) => `R$ ${val/1000}k`}
|
||||
/>
|
||||
<BarChart
|
||||
data={dadosGrafico}
|
||||
layout={isSubgroup ? 'vertical' : 'horizontal'}
|
||||
margin={{ left: isSubgroup ? 40 : 10, right: 30, top: 20, bottom: isSubgroup ? 10 : 60 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={!isSubgroup} horizontal={isSubgroup} className="stroke-slate-200 dark:stroke-slate-700" opacity={0.5} />
|
||||
{isSubgroup ? (
|
||||
<>
|
||||
<XAxis
|
||||
type="number"
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="fill-slate-600 dark:fill-slate-400"
|
||||
tick={{fontSize: 10, fontWeight: 'bold'}}
|
||||
width={120}
|
||||
tickFormatter={(val) => truncateLabel(val, 20)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="fill-slate-600 dark:fill-slate-400"
|
||||
tick={{fontSize: 11, fontWeight: 'bold'}}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
tickFormatter={(val) => truncateLabel(val, 15)}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="fill-slate-600 dark:fill-slate-400"
|
||||
tick={{fontSize: 10}}
|
||||
tickFormatter={(val) => `R$ ${val/1000}k`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Tooltip
|
||||
content={(props) => <ConciliacaoChartTooltip {...props} formatCurrency={formatCurrency} />}
|
||||
cursor={{ fill: 'rgba(0,0,0,0.05)', opacity: 0.1 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={[4, 4, 0, 0]}
|
||||
radius={isSubgroup ? [0, 4, 4, 0] : [4, 4, 0, 0]}
|
||||
barSize={isSubgroup ? 20 : undefined}
|
||||
>
|
||||
{dadosGrafico.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.cor || COLORS[index % COLORS.length]} />
|
||||
|
|
@ -358,54 +386,6 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
} else if (nivelNavegacao === 2) {
|
||||
// Gráfico de Barras Horizontal para Beneficiários/Pagadores
|
||||
return (
|
||||
<Card className="bg-white dark:bg-[#1e293b]/40 border-slate-200 dark:border-slate-800 shadow-xl rounded-lg overflow-hidden mb-6">
|
||||
<CardHeader className="py-4 px-4 sm:px-6 border-b border-slate-200 dark:border-slate-800/50">
|
||||
<CardTitle className="text-sm font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-purple-500" />
|
||||
Distribuição por Beneficiário/Pagador
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="w-full min-w-[300px] h-[400px] min-h-[300px] max-w-2xl mx-auto">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={dadosGrafico} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} className="stroke-slate-200 dark:stroke-slate-700" opacity={0.5} />
|
||||
<XAxis
|
||||
type="number"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="fill-slate-600 dark:fill-slate-400"
|
||||
tick={{fontSize: 10}}
|
||||
tickFormatter={(val) => `R$ ${val/1000}k`}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
className="fill-slate-600 dark:fill-slate-400"
|
||||
tick={{fontSize: 10, fontWeight: 'bold'}}
|
||||
width={200}
|
||||
tickFormatter={(value) => truncateLegend(value, 45)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => <ConciliacaoChartTooltip {...props} formatCurrency={formatCurrency} />}
|
||||
cursor={{ fill: 'rgba(0,0,0,0.05)', opacity: 0.1 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={[0, 4, 4, 0]}
|
||||
fill="#8b5cf6"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
} else if (nivelNavegacao === 3) {
|
||||
// Gráfico de Linha/Timeline para Transações
|
||||
return (
|
||||
|
|
@ -585,50 +565,54 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
</div>
|
||||
);
|
||||
} else if (nivelNavegacao === 2) {
|
||||
// Lista de Beneficiários/Pagadores da Categoria selecionada
|
||||
const tabelaHeightReg = dadosGrafico.length > 0 ? 'h-[400px]' : 'h-[600px]';
|
||||
// Visão de Subgrupos (Agrupamentos dentro da Categoria)
|
||||
const tabelaHeight = dadosGrafico.length > 0 ? 'h-[400px]' : 'h-[600px]';
|
||||
return (
|
||||
<div className={tabelaHeightReg}>
|
||||
<div className={tabelaHeight}>
|
||||
<ExcelTable
|
||||
data={dadosNivelAtual}
|
||||
columns={[
|
||||
{
|
||||
field: 'beneficiario',
|
||||
header: 'Beneficiário / Pagador',
|
||||
width: '350px',
|
||||
field: 'nome',
|
||||
header: 'Subgrupo / Beneficiário',
|
||||
width: '400px',
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center border border-purple-500/20">
|
||||
<Users className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<p className="font-bold text-slate-900 dark:text-white">{row.beneficiario || 'Sem Beneficiário'}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-slate-400" />
|
||||
<span className="font-bold text-slate-900 dark:text-white" title={row.nome}>
|
||||
{truncateLabel(row.nome || 'Sem Categoria', 40)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'totalTransacoes',
|
||||
header: 'Transações',
|
||||
width: '120px',
|
||||
header: 'Lançamentos',
|
||||
width: '150px',
|
||||
render: (row) => (
|
||||
<Badge className="bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||||
{row.totalTransacoes || 0}
|
||||
<Badge variant="outline" className="text-xs font-bold bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border-none">
|
||||
{row.totalTransacoes} transações
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'totalValor',
|
||||
header: 'Valor Total',
|
||||
width: '150px',
|
||||
width: '200px',
|
||||
className: 'text-right',
|
||||
render: (row) => (
|
||||
<span className="font-bold text-slate-900 dark:text-white">
|
||||
<span className={cn(
|
||||
"font-bold font-mono",
|
||||
(row.totalValor || 0) >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
|
||||
)}>
|
||||
{formatCurrency(row.totalValor || 0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]}
|
||||
onRowClick={(row) => actions.navegarPara('regra', row)}
|
||||
onRowClick={(row) => actions.navegarPara('subgrupo', row)}
|
||||
rowKey="id"
|
||||
pageSize={25}
|
||||
pageSize={20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -644,7 +628,7 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
field: 'data',
|
||||
header: 'Data',
|
||||
width: '120px',
|
||||
render: (row) => formatDate(row.data || row.dataEntrada || '')
|
||||
render: (row) => row.data || row.dataEntrada || ''
|
||||
},
|
||||
{
|
||||
field: 'descricao',
|
||||
|
|
@ -732,8 +716,8 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
<span>Transações Conciliadas</span>
|
||||
</div>
|
||||
|
||||
{nivelNavegacao > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{nivelNavegacao > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -743,13 +727,26 @@ export function TransacoesConciliadasView({ state, actions }) {
|
|||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{caminhoNavegacao.map((item, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<span className="font-medium">{item.item.nome || item.item.beneficiario}</span>
|
||||
{idx < caminhoNavegacao.length - 1 && <ChevronRight className="w-4 h-4" />}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] uppercase font-black text-slate-400 tracking-tighter leading-none">{item.tipo}</span>
|
||||
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">{item.item.nome || item.item.beneficiario}</span>
|
||||
</div>
|
||||
{idx < caminhoNavegacao.length - 1 && <ChevronRight className="w-4 h-4 text-slate-300" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{nivelNavegacao === 3 && (
|
||||
<>
|
||||
<ChevronRight className="w-4 h-4 text-slate-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] uppercase font-black text-slate-400 tracking-tighter leading-none">Visão</span>
|
||||
<span className="text-sm font-bold text-blue-600">Lançamentos</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -193,7 +193,8 @@ export const ReconciliationForm = ({ transaction, categories = [], caixinhas = [
|
|||
beneficiario_pagador: '',
|
||||
descricao: '',
|
||||
dataEntrada: '',
|
||||
valor: ''
|
||||
valor: '',
|
||||
tag: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -206,7 +207,8 @@ export const ReconciliationForm = ({ transaction, categories = [], caixinhas = [
|
|||
beneficiario_pagador: transaction.raw?.beneficiario_pagador || '',
|
||||
descricao: transaction.description || '',
|
||||
dataEntrada: transaction.date?.toLocaleDateString('pt-BR') || '',
|
||||
valor: new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(transaction.amount)
|
||||
valor: new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(transaction.amount),
|
||||
tag: transaction.tag || transaction.raw?.tag || ''
|
||||
};
|
||||
console.log('[ReconciliationForm] FormData atualizado:', newFormData);
|
||||
setFormData(newFormData);
|
||||
|
|
@ -313,6 +315,19 @@ export const ReconciliationForm = ({ transaction, categories = [], caixinhas = [
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<label className="text-[10px] font-bold text-slate-600 uppercase tracking-[0.2em] px-1">Tag da Transação</label>
|
||||
<select
|
||||
value={formData.tag}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, tag: e.target.value }))}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white h-12 px-3 text-sm text-slate-900 outline-none focus:border-emerald-500 transition-all font-medium shadow-sm"
|
||||
>
|
||||
<option value="" className="text-slate-400">Selecionar tag...</option>
|
||||
<option value="operacional">Operacional</option>
|
||||
<option value="avulsa">Avulsa</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -438,7 +438,7 @@ export function CruzamentoDespesasView({ state }) {
|
|||
const temPlanejado = totalPlanejado > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 mt-4 sm:mt-6 relative pb-6 sm:pb-10">
|
||||
<div className="space-y-4 sm:space-y-6 relative pb-6 sm:pb-10">
|
||||
{/* Abstract Background Elements */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-4xl h-full pointer-events-none overflow-hidden opacity-10 dark:opacity-20">
|
||||
<div className="absolute top-20 right-0 w-96 h-96 bg-rose-500/10 rounded-full blur-3xl" />
|
||||
|
|
|
|||
|
|
@ -12,22 +12,25 @@ import {
|
|||
Printer,
|
||||
Loader2,
|
||||
Building2,
|
||||
CreditCard
|
||||
CreditCard,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useContasPagar } from '../../hooks/useContasPagar';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ExcelTable from '../../components/ExcelTable';
|
||||
import { formatDate, formatCurrency } from '../../utils/dateUtils';
|
||||
import { formatDate, formatCurrency, toISODate } from '../../utils/dateUtils';
|
||||
import { AutoFillInput } from '@/components/shared/AutoFillInput';
|
||||
import { CurrencyInputV2 } from '../../components/CurrencyInputV2';
|
||||
import { conciliacaoService } from '@/services/conciliacaoService';
|
||||
|
||||
const METODOS_PAGAMENTO = [
|
||||
{ value: 'pix', label: 'PIX' },
|
||||
|
|
@ -38,6 +41,9 @@ const METODOS_PAGAMENTO = [
|
|||
{ value: 'transferencia', label: 'Transferência' }
|
||||
];
|
||||
|
||||
const MESES = ['', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
|
||||
const ANOS = Array.from({ length: 11 }, (_, i) => new Date().getFullYear() - 5 + i);
|
||||
|
||||
export const DespesasView = () => {
|
||||
const { state, actions } = useContasPagar();
|
||||
const toast = useToast();
|
||||
|
|
@ -49,10 +55,29 @@ export const DespesasView = () => {
|
|||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState('todas');
|
||||
|
||||
// Regras
|
||||
const [allRules, setAllRules] = useState([]);
|
||||
const [isRuleConfirmationOpen, setIsRuleConfirmationOpen] = useState(false);
|
||||
const [pendingSupplier, setPendingSupplier] = useState('');
|
||||
|
||||
// Garantir que a subview esteja correta para disparar os fetches do hook
|
||||
useEffect(() => {
|
||||
actions.setActiveSubView('despesas');
|
||||
}, [actions]);
|
||||
}, [actions.setActiveSubView]);
|
||||
|
||||
// Carrega regras para o seletor apenas na montagem
|
||||
useEffect(() => {
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
const res = await conciliacaoService.fetchRules();
|
||||
const raw = res?.data?.dados || res?.data || res || [];
|
||||
setAllRules(Array.isArray(raw) ? raw : []);
|
||||
} catch (err) {
|
||||
console.error('[DespesasView] Erro ao buscar regras:', err);
|
||||
}
|
||||
};
|
||||
fetchRules();
|
||||
}, []);
|
||||
|
||||
// Lista de fornecedores para o AutoFillInput (Mapeando campos reais do backend)
|
||||
const fornecedoresOptions = useMemo(() => {
|
||||
|
|
@ -82,7 +107,11 @@ export const DespesasView = () => {
|
|||
montante: '',
|
||||
categoria: '',
|
||||
descricao: '',
|
||||
metodoPagamento: ''
|
||||
metodoPagamento: '',
|
||||
tipo_cobranca: 'Avulsas',
|
||||
data_lancamento: '',
|
||||
vencimento: '',
|
||||
idregra: ''
|
||||
});
|
||||
|
||||
const [itemFormData, setItemFormData] = useState({
|
||||
|
|
@ -101,9 +130,16 @@ export const DespesasView = () => {
|
|||
|
||||
const handleOpenDialog = (despesa = null) => {
|
||||
if (despesa) {
|
||||
// Tenta encontrar o ID da categoria se tivermos apenas o nome
|
||||
let categoryValue = despesa.categoria || '';
|
||||
const foundCat = categoriasOptions.find(c => c.name === categoryValue || String(c.id) === String(categoryValue));
|
||||
if (foundCat) {
|
||||
categoryValue = String(foundCat.id);
|
||||
}
|
||||
|
||||
setEditingDespesa(despesa);
|
||||
setFormData({
|
||||
data: despesa.data || '',
|
||||
data: toISODate(despesa.data),
|
||||
contaDespesa: despesa.contaDespesa || '',
|
||||
numeroReferencia: despesa.numeroReferencia || '',
|
||||
nomeFornecedor: despesa.nomeFornecedor || '',
|
||||
|
|
@ -111,9 +147,13 @@ export const DespesasView = () => {
|
|||
nomeCliente: despesa.nomeCliente || '',
|
||||
status: despesa.status || 'PENDENTE',
|
||||
montante: Number(despesa.montante || 0),
|
||||
categoria: despesa.categoria || '',
|
||||
categoria: categoryValue,
|
||||
descricao: despesa.descricao || '',
|
||||
metodoPagamento: despesa.metodoPagamento || despesa.pagoPorMeioDe || ''
|
||||
metodoPagamento: despesa.metodoPagamento || despesa.pagoPorMeioDe || '',
|
||||
tipo_cobranca: despesa.tipo_cobranca || 'Avulsas',
|
||||
data_lancamento: toISODate(despesa.data_lancamento),
|
||||
vencimento: despesa.tipo_cobranca === 'Recorrente' ? (despesa.vencimento || '') : (toISODate(despesa.data) || despesa.vencimento || ''),
|
||||
idregra: despesa.idregra || ''
|
||||
});
|
||||
} else {
|
||||
setEditingDespesa(null);
|
||||
|
|
@ -128,24 +168,79 @@ export const DespesasView = () => {
|
|||
montante: '',
|
||||
categoria: '',
|
||||
descricao: '',
|
||||
metodoPagamento: ''
|
||||
metodoPagamento: '',
|
||||
tipo_cobranca: 'Avulsas',
|
||||
data_lancamento: '',
|
||||
vencimento: '',
|
||||
idregra: ''
|
||||
});
|
||||
}
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRuleSelect = (ruleId) => {
|
||||
const selectedRule = allRules.find(r => String(r.idregras_financeiro || r.id) === String(ruleId));
|
||||
if (selectedRule) {
|
||||
const beneficiario = selectedRule.beneficiario_pagador || selectedRule.beneficiario || '';
|
||||
|
||||
if (beneficiario) {
|
||||
// Se já existe um fornecedor diferente, pergunta se deseja substituir
|
||||
if (formData.nomeFornecedor && formData.nomeFornecedor.trim() !== '' && formData.nomeFornecedor !== beneficiario) {
|
||||
setPendingSupplier(beneficiario);
|
||||
setIsRuleConfirmationOpen(true);
|
||||
setFormData(prev => ({ ...prev, idregra: ruleId }));
|
||||
} else {
|
||||
// Preenche silenciosamente
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
nomeFornecedor: beneficiario,
|
||||
idregra: ruleId
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, idregra: ruleId }));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, idregra: ruleId }));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSupplierChange = () => {
|
||||
setFormData(prev => ({ ...prev, nomeFornecedor: pendingSupplier }));
|
||||
setIsRuleConfirmationOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.data || !formData.montante || !formData.categoria) {
|
||||
toast.error('Campos obrigatórios: Data, Montante e Categoria');
|
||||
if (!formData.montante || !formData.categoria) {
|
||||
toast.error('Campos obrigatórios: Montante e Categoria');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingDespesa) {
|
||||
await actions.updateDespesa(editingDespesa.idDespesa, formData);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { data: oldData, data_lancamento, vencimento, ...rest } = formData;
|
||||
|
||||
const payload = {
|
||||
...rest
|
||||
};
|
||||
|
||||
// Se for Recorrente, envia apenas 'vencimento' (dia)
|
||||
if (formData.tipo_cobranca === 'Recorrente') {
|
||||
payload.vencimento = vencimento;
|
||||
} else {
|
||||
await actions.createDespesa(formData);
|
||||
// Se for Avulsa, envia apenas 'data' (data completa)
|
||||
payload.data = vencimento;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
if (editingDespesa) {
|
||||
success = await actions.updateDespesa(editingDespesa.idDespesa, payload);
|
||||
} else {
|
||||
success = await actions.createDespesa(payload);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
|
|
@ -219,53 +314,118 @@ export const DespesasView = () => {
|
|||
{/* Tabela de Despesas */}
|
||||
<Card className="bg-white dark:bg-[#1e293b]/40 backdrop-blur-md border-slate-200 dark:border-slate-800 shadow-xl rounded-lg overflow-hidden">
|
||||
<CardHeader className="py-4 px-4 sm:px-6 border-b border-slate-200 dark:border-slate-800/50 shrink-0 bg-white dark:bg-slate-900/30">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-500/10 dark:bg-rose-500/10 flex items-center justify-center border border-rose-500/20 shrink-0">
|
||||
<Receipt className="w-5 h-5 text-rose-500" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-500/10 dark:bg-rose-500/10 flex items-center justify-center border border-rose-500/20 shrink-0">
|
||||
<Receipt className="w-5 h-5 text-rose-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900 dark:text-white flex items-center gap-2 flex-wrap text-base sm:text-lg">
|
||||
Todas as Despesas
|
||||
<Badge variant="outline" className="text-[10px] border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400 bg-slate-50 dark:bg-slate-900/50">
|
||||
{(state.despesas || []).length} DESPESAS
|
||||
</Badge>
|
||||
{state.despesasLoading && <Loader2 className="w-4 h-4 animate-spin text-slate-400" />}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium uppercase tracking-wider hidden sm:block">Gerencie as despesas da empresa</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900 dark:text-white flex items-center gap-2 flex-wrap text-base sm:text-lg">
|
||||
Todas as Despesas
|
||||
<Badge variant="outline" className="text-[10px] border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400 bg-slate-50 dark:bg-slate-900/50">
|
||||
{(state.despesas || []).length} DESPESAS
|
||||
</Badge>
|
||||
{state.despesasLoading && <Loader2 className="w-4 h-4 animate-spin text-slate-400" />}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium uppercase tracking-wider hidden sm:block">Gerencie as despesas da empresa</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<div className="relative group flex-1 sm:flex-initial">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
|
||||
<Input
|
||||
placeholder="Pesquisar despesa..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-white dark:bg-slate-900/80 border-slate-200 dark:border-slate-700 pl-9 h-9 text-xs text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-600 focus:border-rose-500/50 rounded-lg w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-40 bg-white dark:bg-slate-900/80 border-slate-200 dark:border-slate-700 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas</SelectItem>
|
||||
<SelectItem value="FATURÁVEL">Faturável</SelectItem>
|
||||
<SelectItem value="NÃO FATURÁVEL">Não Faturável</SelectItem>
|
||||
<SelectItem value="PENDENTE">Pendente</SelectItem>
|
||||
<SelectItem value="PAGO">Pago</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white text-xs sm:text-sm"
|
||||
onClick={() => handleOpenDialog()}
|
||||
>
|
||||
<Plus className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Novo</span>
|
||||
<span className="hidden sm:inline">Novo Registro</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtros Padronizados */}
|
||||
<div className="flex flex-wrap items-center gap-2 p-3 bg-slate-50/50 dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Select value={state.filtroTipoPeriodo} onValueChange={actions.setFiltroTipoPeriodo}>
|
||||
<SelectTrigger className="h-9 w-[110px] bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-xs font-semibold">
|
||||
<SelectValue placeholder="Período" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mes">Mês</SelectItem>
|
||||
<SelectItem value="ano">Ano</SelectItem>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{state.filtroTipoPeriodo === 'mes' && (
|
||||
<>
|
||||
<Select value={state.filtroMes} onValueChange={actions.setFiltroMes}>
|
||||
<SelectTrigger className="h-9 w-[120px] bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-xs font-semibold">
|
||||
<SelectValue placeholder="Mês" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MESES.map((m, i) => (i === 0 ? null : <SelectItem key={i} value={String(i)}>{m}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={state.filtroAno} onValueChange={actions.setFiltroAno}>
|
||||
<SelectTrigger className="h-9 w-[90px] bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-xs font-semibold">
|
||||
<SelectValue placeholder="Ano" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANOS.map((a) => <SelectItem key={a} value={String(a)}>{a}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.filtroTipoPeriodo === 'ano' && (
|
||||
<Select value={state.filtroAno} onValueChange={actions.setFiltroAno}>
|
||||
<SelectTrigger className="h-9 w-[90px] bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-xs font-semibold">
|
||||
<SelectValue placeholder="Ano" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANOS.map((a) => <SelectItem key={a} value={String(a)}>{a}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div className="h-4 w-[1px] bg-slate-200 dark:bg-slate-700 mx-1 hidden sm:block" />
|
||||
|
||||
<Select value={state.filtroTipoCobranca} onValueChange={actions.setFiltroTipoCobranca}>
|
||||
<SelectTrigger className="h-9 w-[130px] bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-xs font-semibold">
|
||||
<SelectValue placeholder="Cobrança" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas</SelectItem>
|
||||
<SelectItem value="Recorrente">Recorrente</SelectItem>
|
||||
<SelectItem value="Avulsas">Avulsa</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="h-4 w-[1px] bg-slate-200 dark:bg-slate-700 mx-1 hidden sm:block" />
|
||||
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="h-9 w-[120px] bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-xs font-semibold">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todos Status</SelectItem>
|
||||
<SelectItem value="FATURÁVEL">Faturável</SelectItem>
|
||||
<SelectItem value="NÃO FATURÁVEL">Não Faturável</SelectItem>
|
||||
<SelectItem value="PENDENTE">Pendente</SelectItem>
|
||||
<SelectItem value="PAGO">Pago</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="relative group w-full sm:w-64 mt-2 sm:mt-0">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
|
||||
<Input
|
||||
placeholder="Pesquisar despesa..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 pl-9 h-9 text-xs text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-600 focus:border-rose-500/50 rounded-lg w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
|
|
@ -354,9 +514,19 @@ export const DespesasView = () => {
|
|||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white">Detalhes da Despesa</h3>
|
||||
{getStatusBadge(state.selectedDespesa.status)}
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(state.selectedDespesa.status)}
|
||||
<Badge className={cn(
|
||||
"text-[9px] font-bold tracking-widest uppercase px-2 py-0.5 rounded-sm hover:!bg-opacity-100",
|
||||
state.selectedDespesa.situacao === 'Inativo'
|
||||
? "bg-rose-100 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-200 dark:border-rose-500/20 hover:bg-rose-100 dark:hover:bg-rose-500/10"
|
||||
: "bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20 hover:bg-emerald-100 dark:hover:bg-emerald-500/10"
|
||||
)}>
|
||||
{state.selectedDespesa.situacao || 'Ativo'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -368,7 +538,7 @@ export const DespesasView = () => {
|
|||
>
|
||||
<Edit className="w-4 h-4 mr-2" /> Editar
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-xs h-8"><Printer className="w-4 h-4 mr-2" /> Imprimir</Button>
|
||||
{/* <Button size="sm" variant="ghost" className="text-xs h-8"><Printer className="w-4 h-4 mr-2" /> Imprimir</Button> */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
|
@ -386,116 +556,204 @@ export const DespesasView = () => {
|
|||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<Badge className="bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-200">
|
||||
{state.selectedDespesa.contaDespesa}
|
||||
Nome: {state.selectedDespesa.contaDespesa || 'N/D'}
|
||||
</Badge>
|
||||
<Badge className="bg-rose-100 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-200">
|
||||
{state.selectedDespesa.categoria || 'Sem Categoria'}
|
||||
Categoria: {categoriasOptions.find(c => String(c.id) === String(state.selectedDespesa.categoria))?.name || state.selectedDespesa.categoria || 'Sem Categoria'}
|
||||
</Badge>
|
||||
{state.selectedDespesa.idregra && state.selectedDespesa.idregra !== '0' && (
|
||||
<Badge className="bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-200">
|
||||
Regra: {allRules.find(r => String(r.idregras_financeiro || r.id) === String(state.selectedDespesa.idregra))?.regra || state.selectedDespesa.idregra}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 flex-1 overflow-auto bg-white dark:bg-slate-900/50">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500 font-bold uppercase tracking-wider">Método de Pagamento</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<CreditCard className="w-4 h-4 text-slate-400" />
|
||||
<p className="text-sm font-bold uppercase text-slate-900 dark:text-white">
|
||||
{state.selectedDespesa.metodoPagamento || state.selectedDespesa.pagoPorMeioDe || 'N/D'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500 font-bold uppercase tracking-wider">N° de ref.</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.numeroReferencia || 'N/D'}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-slate-500 font-bold uppercase tracking-wider">Fornecedor</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Building2 className="w-4 h-4 text-slate-400" />
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-white">{state.selectedDespesa.nomeFornecedor || 'N/D'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500 font-bold uppercase tracking-wider">Cliente</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.nomeCliente || 'N/D'}</p>
|
||||
</div>
|
||||
<CardContent className="p-0 flex-1 overflow-hidden bg-white dark:bg-slate-900/50">
|
||||
<Tabs defaultValue="geral" className="h-full flex flex-col">
|
||||
<div className="px-6 border-b border-slate-200 dark:border-slate-800/50 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<TabsList className="bg-transparent h-12 gap-6 p-0">
|
||||
<TabsTrigger value="geral" className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-rose-500 rounded-none h-full px-0 text-xs font-bold uppercase tracking-widest">Geral</TabsTrigger>
|
||||
<TabsTrigger value="financeiro" className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-rose-500 rounded-none h-full px-0 text-xs font-bold uppercase tracking-widest">Financeiro</TabsTrigger>
|
||||
<TabsTrigger value="datas" className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-rose-500 rounded-none h-full px-0 text-xs font-bold uppercase tracking-widest">Datas</TabsTrigger>
|
||||
<TabsTrigger value="diario" className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-rose-500 rounded-none h-full px-0 text-xs font-bold uppercase tracking-widest">Diário</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Diário de Lançamentos */}
|
||||
<div className="border-t border-slate-200 dark:border-slate-800 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<TabsContent value="geral" className="m-0 space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Conta de Despesa</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.contaDespesa || 'N/D'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">ID Despesa</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">#{state.selectedDespesa.idDespesa || 'N/D'}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Fornecedor</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Building2 className="w-4 h-4 text-slate-400" />
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-white">{state.selectedDespesa.nomeFornecedor || 'N/D'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Cliente</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.nomeCliente || 'N/D'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Tipo de Cobrança</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.tipo_cobranca || 'N/D'}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Descrição</Label>
|
||||
<div className="mt-2 text-sm text-slate-600 dark:text-slate-400 leading-relaxed bg-slate-50 dark:bg-slate-800/50 p-4 rounded-xl border border-slate-100 dark:border-slate-800 italic">
|
||||
"{state.selectedDespesa.descricao || 'Nenhuma descrição informada.'}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="financeiro" className="m-0 space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-rose-50/50 dark:bg-rose-500/5 p-3 rounded-lg border border-rose-100 dark:border-rose-500/10">
|
||||
<Label className="text-[10px] text-rose-600 dark:text-rose-400 font-black uppercase tracking-widest">Montante Previsto</Label>
|
||||
<p className="text-lg font-black mt-1 text-rose-600 dark:text-rose-400">
|
||||
{formatCurrency(state.selectedDespesa.montante)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50/50 dark:bg-emerald-500/5 p-3 rounded-lg border border-emerald-100 dark:border-emerald-500/10">
|
||||
<Label className="text-[10px] text-emerald-600 dark:text-emerald-400 font-black uppercase tracking-widest">Montante Real Pago</Label>
|
||||
<p className="text-lg font-black mt-1 text-emerald-600 dark:text-emerald-400">
|
||||
{state.selectedDespesa.montante_real_pago ? formatCurrency(state.selectedDespesa.montante_real_pago) : 'N/D'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Categoria</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">
|
||||
{categoriasOptions.find(c => String(c.id) === String(state.selectedDespesa.categoria))?.name || state.selectedDespesa.categoria || 'Sem Categoria'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Método de Pagamento</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<CreditCard className="w-4 h-4 text-slate-400" />
|
||||
<p className="text-sm font-bold uppercase text-slate-900 dark:text-white">
|
||||
{state.selectedDespesa.metodoPagamento || 'N/D'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Pago por Meio de</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white uppercase line-clamp-1">
|
||||
{state.selectedDespesa.pagoPorMeioDe || 'N/D'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">ID Regra</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.idregra || 'N/D'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="datas" className="m-0 space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Data do Registro</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{formatDate(state.selectedDespesa.data) || 'N/D'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Data do Pagamento</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{formatDate(state.selectedDespesa.data_pagamento) || 'N/D'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Vencimento / Dia</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.vencimento || 'N/D'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500 font-black uppercase tracking-widest">N° de Ref.</Label>
|
||||
<p className="text-sm font-bold mt-1 text-slate-900 dark:text-white">{state.selectedDespesa.numeroReferencia || 'N/D'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="diario" className="m-0 space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
{/* Diário de Lançamentos */}
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white">Diário de Lançamentos</h4>
|
||||
<p className="text-[10px] text-slate-500 uppercase font-medium">Lançamentos contábeis vinculados</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-rose-200 text-rose-600 hover:bg-rose-50"
|
||||
onClick={() => handleOpenItemDialog()}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" /> Adicionar Item
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-tight">Diário de Lançamentos</h4>
|
||||
<p className="text-[10px] text-slate-500 uppercase font-medium">Lançamentos contábeis vinculados</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-rose-200 text-rose-600 hover:bg-rose-50 dark:border-rose-500/20 dark:text-rose-400 dark:hover:bg-rose-500/10 dark:hover:text-rose-300 transition-colors"
|
||||
onClick={() => handleOpenItemDialog()}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" /> Adicionar Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state.itensDespesaLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-rose-500" />
|
||||
{state.itensDespesaLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-rose-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden border border-slate-200 dark:border-slate-800 rounded-xl">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-bold text-slate-600">CONTA</th>
|
||||
<th className="text-right px-3 py-2 font-bold text-slate-600">DÉBITO</th>
|
||||
<th className="text-right px-3 py-2 font-bold text-slate-600">CRÉDITO</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{(state.selectedDespesa.diario || []).map((item) => (
|
||||
<tr key={item.idDiario} className="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 group">
|
||||
<td className="px-3 py-2.5 font-medium text-slate-900 dark:text-white">{item.conta}</td>
|
||||
<td className="px-3 py-2.5 text-right font-bold text-slate-700 dark:text-slate-300">{formatCurrency(item.debito)}</td>
|
||||
<td className="px-3 py-2.5 text-right font-bold text-slate-700 dark:text-slate-300">{formatCurrency(item.credito)}</td>
|
||||
<td className="px-3 py-2.5 text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-400 hover:text-blue-500" onClick={() => handleOpenItemDialog(item)}>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-400 hover:text-rose-500" onClick={() => handleDeleteItem(item.idDiario)}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!state.selectedDespesa.diario || state.selectedDespesa.diario.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-8 text-center text-slate-400 italic">Nenhum lançamento no diário.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot className="bg-slate-50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 font-bold">
|
||||
<tr>
|
||||
<td className="px-3 py-2 uppercase tracking-widest text-[10px]">Total Geral (Backend)</td>
|
||||
<td className="px-3 py-2 text-right text-rose-600">
|
||||
{formatCurrency(state.totalItensDespesa.debito)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-emerald-600">
|
||||
{formatCurrency(state.totalItensDespesa.credito)}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden border border-slate-200 dark:border-slate-800 rounded-lg">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-bold text-slate-600">CONTA</th>
|
||||
<th className="text-right px-3 py-2 font-bold text-slate-600">DÉBITO</th>
|
||||
<th className="text-right px-3 py-2 font-bold text-slate-600">CRÉDITO</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{(state.selectedDespesa.diario || []).map((item) => (
|
||||
<tr key={item.idDiario} className="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 group">
|
||||
<td className="px-3 py-2.5 font-medium text-slate-900 dark:text-white">{item.conta}</td>
|
||||
<td className="px-3 py-2.5 text-right font-bold text-slate-700 dark:text-slate-300">{formatCurrency(item.debito)}</td>
|
||||
<td className="px-3 py-2.5 text-right font-bold text-slate-700 dark:text-slate-300">{formatCurrency(item.credito)}</td>
|
||||
<td className="px-3 py-2.5 text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-400 hover:text-blue-500" onClick={() => handleOpenItemDialog(item)}>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-400 hover:text-rose-500" onClick={() => handleDeleteItem(item.idDiario)}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!state.selectedDespesa.diario || state.selectedDespesa.diario.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-8 text-center text-slate-400 italic">Nenhum lançamento no diário.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot className="bg-slate-50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 font-bold">
|
||||
<tr>
|
||||
<td className="px-3 py-2 uppercase tracking-widest text-[10px]">Total Geral (Backend)</td>
|
||||
<td className="px-3 py-2 text-right text-rose-600">
|
||||
{formatCurrency(state.totalItensDespesa.debito)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-emerald-600">
|
||||
{formatCurrency(state.totalItensDespesa.credito)}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* Upload de Recibos */}
|
||||
{/* <div>
|
||||
|
|
@ -507,7 +765,6 @@ export const DespesasView = () => {
|
|||
<Button size="sm" variant="outline" className="h-8 border-slate-300">Selecionar arquivo</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -524,19 +781,22 @@ export const DespesasView = () => {
|
|||
</div>
|
||||
{editingDespesa ? 'Editar Registro' : 'Novo Registro de Despesa'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-black uppercase tracking-widest text-slate-400 mt-2">
|
||||
Preencha os dados para organizar seu contas a pagar
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-6 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Data da Despesa *</Label>
|
||||
{/* <div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Data de Pagamento *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.data}
|
||||
onChange={(e) => setFormData({ ...formData, data: e.target.value })}
|
||||
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4 font-medium transition-all focus:ring-2 focus:ring-rose-500/10 focus:border-rose-500"
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Conta de Despesa *</Label>
|
||||
|
|
@ -548,10 +808,20 @@ export const DespesasView = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Nome do Cliente (Opcional)</Label>
|
||||
<Input
|
||||
value={formData.nomeCliente}
|
||||
onChange={(e) => setFormData({ ...formData, nomeCliente: e.target.value })}
|
||||
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<AutoFillInput
|
||||
label="Fornecedor"
|
||||
placeholder="Pesquisar fornecedor existente ou digitar..."
|
||||
value={formData.nomeFornecedor}
|
||||
data={fornecedoresOptions}
|
||||
displayField="name"
|
||||
filterField="name"
|
||||
|
|
@ -576,7 +846,7 @@ export const DespesasView = () => {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoriasOptions.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.name}>{cat.name}</SelectItem>
|
||||
<SelectItem key={cat.id} value={String(cat.id)}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -608,6 +878,26 @@ export const DespesasView = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Regra de Conciliação</Label>
|
||||
<Select
|
||||
value={String(formData.idregra)}
|
||||
onValueChange={handleRuleSelect}
|
||||
>
|
||||
<SelectTrigger className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4">
|
||||
<SelectValue placeholder="Selecione uma regra..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Nenhuma Regra</SelectItem>
|
||||
{allRules.map(rule => (
|
||||
<SelectItem key={rule.idregras_financeiro || rule.id} value={String(rule.idregras_financeiro || rule.id)}>
|
||||
{rule.regra || rule.nome || 'Regra s/ nome'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Montante (R$) *</Label>
|
||||
<CurrencyInputV2
|
||||
|
|
@ -617,7 +907,7 @@ export const DespesasView = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* <div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Status do Registro *</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
|
|
@ -633,17 +923,87 @@ export const DespesasView = () => {
|
|||
<SelectItem value="NÃO FATURÁVEL">Não Faturável</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Tipo de Cobrança *</Label>
|
||||
<Select
|
||||
value={formData.tipo_cobranca}
|
||||
onValueChange={(val) => setFormData({ ...formData, tipo_cobranca: val })}
|
||||
>
|
||||
<SelectTrigger className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4">
|
||||
<SelectValue placeholder="Selecione o tipo..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Avulsas">Avulsa</SelectItem>
|
||||
<SelectItem value="Recorrente">Recorrente</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Nome do Cliente (Opcional)</Label>
|
||||
<Input
|
||||
value={formData.nomeCliente}
|
||||
onChange={(e) => setFormData({ ...formData, nomeCliente: e.target.value })}
|
||||
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4"
|
||||
/>
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Situação *</Label>
|
||||
<Select
|
||||
value={formData.situacao || 'Ativo'}
|
||||
onValueChange={(val) => setFormData({ ...formData, situacao: val })}
|
||||
>
|
||||
<SelectTrigger className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4">
|
||||
<SelectValue placeholder="Selecione a situação..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Ativo">Ativo</SelectItem>
|
||||
<SelectItem value="Inativo">Inativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">
|
||||
{formData.tipo_cobranca === 'Recorrente' ? 'Dia de Vencimento' : 'Data de Vencimento'}
|
||||
</Label>
|
||||
{formData.tipo_cobranca === 'Recorrente' ? (
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
placeholder="Ex: 10"
|
||||
value={formData.vencimento}
|
||||
onChange={(e) => setFormData({ ...formData, vencimento: e.target.value })}
|
||||
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4 font-medium"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.vencimento}
|
||||
onChange={(e) => setFormData({ ...formData, vencimento: e.target.value })}
|
||||
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4 font-medium"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Data de Lançamento</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.data_lancamento}
|
||||
onChange={(e) => setFormData({ ...formData, data_lancamento: e.target.value })}
|
||||
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4 font-medium"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Dia de Vencimento</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
placeholder="Ex: 10"
|
||||
value={formData.vencimento}
|
||||
onChange={(e) => setFormData({ ...formData, vencimento: e.target.value })}
|
||||
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4 font-medium"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Descrição Adicional</Label>
|
||||
<textarea
|
||||
|
|
@ -685,7 +1045,9 @@ export const DespesasView = () => {
|
|||
</div>
|
||||
{editingItem ? 'Editar Lançamento' : 'Novo Lançamento'}
|
||||
</DialogTitle>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400">Adicione uma conta ao diário desta despesa</p>
|
||||
<DialogDescription className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
Adicione uma conta ao diário desta despesa
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
@ -737,6 +1099,38 @@ export const DespesasView = () => {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de Confirmação de Regra -> Fornecedor */}
|
||||
<Dialog open={isRuleConfirmationOpen} onOpenChange={setIsRuleConfirmationOpen}>
|
||||
<DialogContent className="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 max-w-sm rounded-2xl p-6">
|
||||
<DialogHeader className="flex flex-col items-center text-center space-y-4">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-100 dark:bg-amber-500/10 flex items-center justify-center border border-amber-200 dark:border-amber-500/20">
|
||||
<AlertCircle className="w-8 h-8 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-lg font-bold text-slate-900 dark:text-white text-center">Ajustar Fornecedor?</DialogTitle>
|
||||
<DialogDescription className="text-sm text-slate-500 dark:text-slate-400 text-center">
|
||||
Deseja ajustar o fornecedor para <span className="font-bold text-slate-900 dark:text-white">"{pendingSupplier}"</span> de acordo com a regra selecionada?
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-3 w-full pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 rounded-xl"
|
||||
onClick={() => setIsRuleConfirmationOpen(false)}
|
||||
>
|
||||
Manter atual
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white rounded-xl"
|
||||
onClick={confirmSupplierChange}
|
||||
>
|
||||
Sim, ajustar
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { toast } from 'sonner';
|
|||
import { cn } from '@/lib/utils';
|
||||
import ExcelTable from '../../components/ExcelTable';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { formatDate, formatCurrency } from '../../utils/dateUtils';
|
||||
import { formatDate, formatCurrency, formatStatus } from '../../utils/dateUtils';
|
||||
import { BoletoCreationDialog } from '../../components/BoletoCreationDialog';
|
||||
|
||||
const TIPOS_BOLETO = [
|
||||
|
|
@ -946,7 +946,7 @@ export const BoletosView = ({ initialFilter = 'TODOS' }) => {
|
|||
<ItemDetailPanel
|
||||
title={selectedBoleto.nome || selectedBoleto.pagador || selectedBoleto.cliente || 'N/D'}
|
||||
subtitle={`Seu Número: ${selectedBoleto.seuNumero || selectedBoleto.numero || 'N/D'}`}
|
||||
status={selectedBoleto.situacao || selectedBoleto.status}
|
||||
status={formatStatus(selectedBoleto.situacao || selectedBoleto.status)}
|
||||
statusColor={
|
||||
selectedBoleto.status === 'RECEBIDO' || selectedBoleto.status === 'PAGO'
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
|
|
@ -1079,7 +1079,7 @@ export const BoletosView = ({ initialFilter = 'TODOS' }) => {
|
|||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-1">Situação</p>
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-white">{selectedBoleto.situacao || selectedBoleto.status}</p>
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-white">{formatStatus(selectedBoleto.situacao || selectedBoleto.status)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { cn } from '@/lib/utils';
|
|||
import { extratoService } from '@/services/extratoService';
|
||||
import { boletosService } from '@/services/boletosService';
|
||||
|
||||
import { formatDate, formatCurrency } from '../../utils/dateUtils';
|
||||
import { formatDate, formatCurrency, formatStatus } from '../../utils/dateUtils';
|
||||
import { StatementRow } from '../../components/StatementRow';
|
||||
import { useStatementRefData } from '../../hooks/useStatementRefData';
|
||||
|
||||
|
|
@ -45,6 +45,13 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
|
|||
const [clientInterest, setClientInterest] = useState([]);
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
|
||||
// Filtros para a aba Juros
|
||||
const [jurosFilters, setJurosFilters] = useState({
|
||||
situacao: '',
|
||||
mes: '',
|
||||
ano: ''
|
||||
});
|
||||
|
||||
const { getCategoryName, getRuleName } = useStatementRefData();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
|
@ -66,17 +73,21 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
|
|||
if (selectedClient && viewMode === 'detail') {
|
||||
loadClientFinancials();
|
||||
}
|
||||
}, [selectedClient, viewMode]);
|
||||
}, [selectedClient, viewMode, jurosFilters]);
|
||||
|
||||
const loadClientFinancials = async () => {
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const clientName = selectedClient.nome || '';
|
||||
const idempresa = selectedClient.idempresa;
|
||||
const seuNumero = selectedClient.seuNumero || selectedClient.seu_numero || '';
|
||||
|
||||
// 1. Busca Transações via nova rota /beneficiario_aplicado
|
||||
const transactionsData = await extratoService.fetchBeneficiarioAplicado(clientName);
|
||||
setTransactions(transactionsData);
|
||||
const transactionsData = await extratoService.fetchBeneficiarioAplicado({
|
||||
beneficiario_pagador: clientName,
|
||||
seuNumero: seuNumero
|
||||
});
|
||||
setTransactions(transactionsData?.linha_tempo || []);
|
||||
|
||||
// 2. Busca Boletos (Faturas) - Mantido fetchBoletosStatus por enquanto ou filtragem local se necessário
|
||||
// O usuário não pediu para mudar a rota de faturas, mas pediu para mudar a de transações
|
||||
|
|
@ -91,7 +102,7 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
|
|||
|
||||
// 3. Busca Juros Detalhe via nova rota /financeiro/cliente/boletos
|
||||
if (idempresa) {
|
||||
const interestData = await boletosService.fetchJurosCliente(idempresa);
|
||||
const interestData = await boletosService.fetchJurosCliente(idempresa, jurosFilters);
|
||||
setClientInterest(interestData);
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +309,7 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
|
|||
(invoice.status === 'ATRASADO' || invoice.status === 'VENCIDO') ? "bg-red-100 text-red-700" :
|
||||
"bg-slate-100 text-slate-600"
|
||||
)}>
|
||||
{invoice.status || 'PENDENTE'}
|
||||
{formatStatus(invoice.status || 'PENDENTE')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -346,13 +357,76 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
|
|||
|
||||
<TabsContent value="juros">
|
||||
<Card className="border border-slate-100 shadow-sm bg-white overflow-hidden rounded-3xl">
|
||||
<div className="p-6 border-b border-slate-50 flex justify-between items-center bg-slate-50/50">
|
||||
<div className="p-6 border-b border-slate-50 flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-50/50">
|
||||
<h4 className="font-bold text-slate-800 text-[10px] uppercase tracking-[0.2em] flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center">
|
||||
<Percent className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
Detalhamento de Juros
|
||||
</h4>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={jurosFilters.situacao || 'TODOS'}
|
||||
onValueChange={(v) => setJurosFilters(prev => ({ ...prev, situacao: v === 'TODOS' ? '' : v }))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[110px] text-[9px] font-bold uppercase bg-white border-slate-200">
|
||||
<SelectValue placeholder="Situação" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TODOS">Situação</SelectItem>
|
||||
<SelectItem value="ATRASADO">Atrasado</SelectItem>
|
||||
<SelectItem value="CANCELADO">Cancelado</SelectItem>
|
||||
<SelectItem value="RECEBIDO">Recebido</SelectItem>
|
||||
<SelectItem value="A_RECEBER">A Receber</SelectItem>
|
||||
<SelectItem value="MARCADO_RECEBIDO">Marcado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={jurosFilters.mes || 'M'}
|
||||
onValueChange={(v) => setJurosFilters(prev => ({ ...prev, mes: v === 'M' ? '' : v }))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[90px] text-[9px] font-bold uppercase bg-white border-slate-200">
|
||||
<SelectValue placeholder="Mês" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="M">Mês</SelectItem>
|
||||
{[
|
||||
{ id: 1, name: 'Jan' },
|
||||
{ id: 2, name: 'Fev' },
|
||||
{ id: 3, name: 'Mar' },
|
||||
{ id: 4, name: 'Abr' },
|
||||
{ id: 5, name: 'Mai' },
|
||||
{ id: 6, name: 'Jun' },
|
||||
{ id: 7, name: 'Jul' },
|
||||
{ id: 8, name: 'Ago' },
|
||||
{ id: 9, name: 'Set' },
|
||||
{ id: 10, name: 'Out' },
|
||||
{ id: 11, name: 'Nov' },
|
||||
{ id: 12, name: 'Dez' }
|
||||
].map(m => (
|
||||
<SelectItem key={m.id} value={String(m.id)}>{m.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={jurosFilters.ano || 'A'}
|
||||
onValueChange={(v) => setJurosFilters(prev => ({ ...prev, ano: v === 'A' ? '' : v }))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[70px] text-[9px] font-bold uppercase bg-white border-slate-200">
|
||||
<SelectValue placeholder="Ano" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">Ano</SelectItem>
|
||||
{['2024', '2025', '2026'].map(y => (
|
||||
<SelectItem key={y} value={y}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="ghost" className="h-8 text-[10px] font-bold uppercase text-amber-600 hover:text-amber-700 hover:bg-amber-50 tracking-widest leading-none">
|
||||
{loadingData ? '...' : `${clientInterest.length} registros`}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -124,6 +124,12 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
const [clientServicosAtribuidos, setClientServicosAtribuidos] = useState([]);
|
||||
const [loadingServicosAtribuidos, setLoadingServicosAtribuidos] = useState(false);
|
||||
|
||||
// Filtros para a aba Juros
|
||||
const [jurosFilters, setJurosFilters] = useState({
|
||||
situacao: '',
|
||||
mes: '',
|
||||
ano: ''
|
||||
});
|
||||
// Dialog de adicionar serviço ao cliente
|
||||
const [isServicoDialogOpen, setIsServicoDialogOpen] = useState(false);
|
||||
const [editingServico, setEditingServico] = useState(null);
|
||||
|
|
@ -231,10 +237,10 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
setClientServicosAtribuidos([]);
|
||||
return;
|
||||
}
|
||||
const dominio = selectedClient.dominio ?? '';
|
||||
const idempresa = selectedClient.idempresa ?? selectedClient.id;
|
||||
setLoadingServicosAtribuidos(true);
|
||||
workspaceReceitasService
|
||||
.fetchServicosMapeamentoCliente(dominio)
|
||||
.fetchServicosMapeamentoCliente(idempresa)
|
||||
.then((list) => setClientServicosAtribuidos(Array.isArray(list) ? list : []))
|
||||
.catch(() => setClientServicosAtribuidos([]))
|
||||
.finally(() => setLoadingServicosAtribuidos(false));
|
||||
|
|
@ -250,10 +256,14 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
const fetchExtratoData = async () => {
|
||||
setLoadingExtrato(true);
|
||||
const clientName = selectedClient.nome || '';
|
||||
const seuNumero = selectedClient.seuNumero || selectedClient.seu_numero || '';
|
||||
|
||||
try {
|
||||
// Rota /beneficiario_aplicado retorna { categoria, linha_tempo }
|
||||
const data = await extratoService.fetchBeneficiarioAplicado(clientName);
|
||||
const data = await extratoService.fetchBeneficiarioAplicado({
|
||||
beneficiario_pagador: clientName,
|
||||
seuNumero: seuNumero
|
||||
});
|
||||
setClientExtrato(
|
||||
data && typeof data === 'object' && Array.isArray(data.linha_tempo)
|
||||
? data
|
||||
|
|
@ -268,7 +278,7 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
};
|
||||
|
||||
fetchExtratoData();
|
||||
}, [clientTab, selectedClient?.idempresa ?? selectedClient?.id, selectedClient?.nome]);
|
||||
}, [clientTab, selectedClient?.idempresa ?? selectedClient?.id, selectedClient?.nome, selectedClient?.seuNumero, selectedClient?.seu_numero]);
|
||||
|
||||
// Carrega detalhes de juros quando abrir a aba Juros
|
||||
useEffect(() => {
|
||||
|
|
@ -283,7 +293,7 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
|
||||
try {
|
||||
if (idempresa) {
|
||||
const data = await boletosService.fetchJurosCliente(idempresa);
|
||||
const data = await boletosService.fetchJurosCliente(idempresa, jurosFilters);
|
||||
setClientInterest(data);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -295,7 +305,7 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
};
|
||||
|
||||
fetchInterestData();
|
||||
}, [clientTab, selectedClient?.idempresa ?? selectedClient?.id]);
|
||||
}, [clientTab, selectedClient?.idempresa ?? selectedClient?.id, jurosFilters]);
|
||||
|
||||
// Carrega caixinhas ao abrir o Dialog de cliente (mesma rota da conciliação: GET /caixinhas/apresentar)
|
||||
useEffect(() => {
|
||||
|
|
@ -415,9 +425,9 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
setIsServicoDialogOpen(false);
|
||||
setEditingServico(null);
|
||||
// Recarrega serviços atribuídos
|
||||
if (selectedClient?.dominio) {
|
||||
const list = await workspaceReceitasService.fetchServicosMapeamentoCliente(selectedClient.dominio);
|
||||
setClientServicosAtribuidos(Array.isArray(list) ? list : []);
|
||||
const idempresa = selectedClient.idempresa ?? selectedClient.id;
|
||||
if (idempresa) {
|
||||
const list = await workspaceReceitasService.fetchServicosMapeamentoCliente(idempresa);
|
||||
setClientServicosAtribuidos(Array.isArray(list) ? list : []);
|
||||
}
|
||||
// Recarrega lista de clientes para atualizar valores calculados
|
||||
|
|
@ -442,9 +452,9 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
try {
|
||||
await workspaceReceitasService.deleteServicoEmpresa({ idempresa_servico });
|
||||
// Recarrega serviços atribuídos
|
||||
if (selectedClient?.dominio) {
|
||||
const list = await workspaceReceitasService.fetchServicosMapeamentoCliente(selectedClient.dominio);
|
||||
setClientServicosAtribuidos(Array.isArray(list) ? list : []);
|
||||
const idempresa = selectedClient.idempresa ?? selectedClient.id;
|
||||
if (idempresa) {
|
||||
const list = await workspaceReceitasService.fetchServicosMapeamentoCliente(idempresa);
|
||||
setClientServicosAtribuidos(Array.isArray(list) ? list : []);
|
||||
}
|
||||
// Recarrega lista de clientes para atualizar valores calculados
|
||||
|
|
@ -1066,7 +1076,7 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<Label className="text-[10px] sm:text-xs font-medium text-slate-600 dark:text-slate-400">CONTAS A RECEBER PENDENTES</Label>
|
||||
<Label className="text-[10px] sm:text-xs font-medium text-slate-600 dark:text-slate-400">VALOR DO CONTRATO</Label>
|
||||
<p className="text-base sm:text-lg font-bold text-slate-900 dark:text-white mt-1">
|
||||
{formatCurrency(selectedClient.valor_servico || 0)}
|
||||
</p>
|
||||
|
|
@ -1442,13 +1452,87 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
|
|||
|
||||
<TabsContent value="juros" className="flex-1 overflow-auto p-4 sm:p-6 m-0 bg-white dark:bg-slate-900/50">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-2">
|
||||
<h4 className="text-base sm:text-lg font-bold text-slate-900 dark:text-white">Detalhamento de Boletos/Juros</h4>
|
||||
<Badge variant="outline" className="text-xs bg-amber-500/10 text-amber-600 border-amber-500/20">
|
||||
{clientInterest.length} registros
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Filtros de Juros */}
|
||||
<div className="flex flex-wrap gap-2 pb-2">
|
||||
<Select
|
||||
value={jurosFilters.situacao || 'TODOS'}
|
||||
onValueChange={(v) => setJurosFilters(prev => ({ ...prev, situacao: v === 'TODOS' ? '' : v }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] text-[10px] font-bold uppercase bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800">
|
||||
<SelectValue placeholder="Situação" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TODOS">Todas Situações</SelectItem>
|
||||
<SelectItem value="ATRASADO">Atrasado</SelectItem>
|
||||
<SelectItem value="CANCELADO">Cancelado</SelectItem>
|
||||
<SelectItem value="RECEBIDO">Recebido</SelectItem>
|
||||
<SelectItem value="A_RECEBER">A Receber</SelectItem>
|
||||
<SelectItem value="MARCADO_RECEBIDO">Marcado Recebido</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={jurosFilters.mes || 'TODOS'}
|
||||
onValueChange={(v) => setJurosFilters(prev => ({ ...prev, mes: v === 'TODOS' ? '' : v }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] text-[10px] font-bold uppercase bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800">
|
||||
<SelectValue placeholder="Mês" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TODOS">Todos os Meses</SelectItem>
|
||||
{[
|
||||
{ id: 1, name: 'Janeiro' },
|
||||
{ id: 2, name: 'Fevereiro' },
|
||||
{ id: 3, name: 'Março' },
|
||||
{ id: 4, name: 'Abril' },
|
||||
{ id: 5, name: 'Maio' },
|
||||
{ id: 6, name: 'Junho' },
|
||||
{ id: 7, name: 'Julho' },
|
||||
{ id: 8, name: 'Agosto' },
|
||||
{ id: 9, name: 'Setembro' },
|
||||
{ id: 10, name: 'Outubro' },
|
||||
{ id: 11, name: 'Novembro' },
|
||||
{ id: 12, name: 'Dezembro' }
|
||||
].map(m => (
|
||||
<SelectItem key={m.id} value={String(m.id)}>{m.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={jurosFilters.ano || 'TODOS'}
|
||||
onValueChange={(v) => setJurosFilters(prev => ({ ...prev, ano: v === 'TODOS' ? '' : v }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[100px] text-[10px] font-bold uppercase bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800">
|
||||
<SelectValue placeholder="Ano" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TODOS">Todos Anos</SelectItem>
|
||||
{['2024', '2025', '2026'].map(y => (
|
||||
<SelectItem key={y} value={y}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(jurosFilters.situacao || jurosFilters.mes || jurosFilters.ano) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setJurosFilters({ situacao: '', mes: '', ano: '' })}
|
||||
className="h-8 text-[10px] font-bold uppercase text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingInterest ? (
|
||||
<div className="flex items-center justify-center py-12 gap-2 text-slate-500 dark:text-slate-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ export function CruzamentoView({ data = [], kpis = {}, caixas = [], entradasPlan
|
|||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#f43f5e'];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 mt-4 sm:mt-6 relative pb-6 sm:pb-10">
|
||||
<div className="space-y-4 sm:space-y-6 relative pb-6 sm:pb-10">
|
||||
{/* Abstract Background Elements */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-4xl h-full pointer-events-none overflow-hidden opacity-20">
|
||||
<div className="absolute top-20 right-0 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Filter,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MONTHS = [
|
||||
{ value: 1, label: 'Janeiro' },
|
||||
{ value: 2, label: 'Fevereiro' },
|
||||
{ value: 3, label: 'Março' },
|
||||
{ value: 4, label: 'Abril' },
|
||||
{ value: 5, label: 'Maio' },
|
||||
{ value: 6, label: 'Junho' },
|
||||
{ value: 7, label: 'Julho' },
|
||||
{ value: 8, label: 'Agosto' },
|
||||
{ value: 9, label: 'Setembro' },
|
||||
{ value: 10, label: 'Outubro' },
|
||||
{ value: 11, label: 'Novembro' },
|
||||
{ value: 12, label: 'Dezembro' },
|
||||
];
|
||||
|
||||
const YEARS = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 2 + i);
|
||||
|
||||
export const GrPeriodSelector = ({ filters, setFilters, loading }) => {
|
||||
const { mes, ano, modo } = filters;
|
||||
|
||||
const handleModoChange = (newModo) => {
|
||||
setFilters(prev => ({ ...prev, modo: newModo }));
|
||||
};
|
||||
|
||||
const handleMesChange = (newMes) => {
|
||||
setFilters(prev => ({ ...prev, mes: parseInt(newMes) }));
|
||||
};
|
||||
|
||||
const handleAnoChange = (newAno) => {
|
||||
setFilters(prev => ({ ...prev, ano: parseInt(newAno) }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 p-1.5 bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-2xl shadow-sm">
|
||||
{/* Modo Selector */}
|
||||
<div className="flex bg-slate-100 dark:bg-[#2a2a2a] p-1 rounded-xl">
|
||||
<button
|
||||
onClick={() => handleModoChange('mensal')}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-all",
|
||||
modo === 'mensal'
|
||||
? "bg-white dark:bg-[#3b82f6] text-[var(--gr-primary)] dark:text-white shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Mensal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModoChange('anual')}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-all",
|
||||
modo === 'anual'
|
||||
? "bg-white dark:bg-[#3b82f6] text-[var(--gr-primary)] dark:text-white shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Anual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-slate-200 dark:bg-slate-700 mx-1 hidden md:block" />
|
||||
|
||||
{/* Mes Selector */}
|
||||
{modo === 'mensal' && (
|
||||
<div className="w-[140px]">
|
||||
<Select value={mes.toString()} onValueChange={handleMesChange} disabled={loading}>
|
||||
<SelectTrigger className="h-9 border-none bg-transparent hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors focus:ring-0">
|
||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||
<Calendar size={14} className="text-[var(--gr-primary)]" />
|
||||
<SelectValue placeholder="Mês" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value.toString()} className="text-xs font-medium">
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ano Selector */}
|
||||
<div className="w-[100px]">
|
||||
<Select value={ano.toString()} onValueChange={handleAnoChange} disabled={loading}>
|
||||
<SelectTrigger className="h-9 border-none bg-transparent hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors focus:ring-0">
|
||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||
<span className="text-[var(--gr-primary)] font-bold text-xs">#</span>
|
||||
<SelectValue placeholder="Ano" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{YEARS.map((y) => (
|
||||
<SelectItem key={y} value={y.toString()} className="text-xs font-medium">
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="px-2">
|
||||
<div className="w-4 h-4 border-2 border-[var(--gr-primary)] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -521,7 +521,7 @@ export const GrRegistrationDetails = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 pb-4">
|
||||
<div className="space-y-1 col-span-2">
|
||||
{/* <div className="space-y-1 col-span-2">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Status Global</label>
|
||||
<select
|
||||
value={formData.global_status || ''}
|
||||
|
|
@ -535,7 +535,7 @@ export const GrRegistrationDetails = ({
|
|||
<option value="Aprovado">Aprovado</option>
|
||||
<option value="Recusado">Recusado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Observações do Cliente</label>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import './GrSidebar.css';
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
id: 'op-geral',
|
||||
label: 'Visão Geral',
|
||||
path: '/plataforma/gr/operacoes/dashboard',
|
||||
icon: LayoutDashboard
|
||||
},
|
||||
{
|
||||
id: 'cadastros',
|
||||
label: 'Meus Cadastros',
|
||||
|
|
@ -43,21 +49,22 @@ const MENU_ITEMS = [
|
|||
icon: FileText,
|
||||
path: '/plataforma/gr/contratos'
|
||||
},
|
||||
|
||||
|
||||
// {
|
||||
// id: 'operacoes',
|
||||
// label: 'Minhas Atividades',
|
||||
// icon: Activity,
|
||||
// path: '/plataforma/gr/operacoes',
|
||||
// children: [
|
||||
// //{ id: 'op-geral', label: 'Visão Geral', path: '/plataforma/gr/operacoes/dashboard', icon: LayoutDashboard },
|
||||
// // { id: 'op-monitoramento', label: 'Monitoramento', path: '/plataforma/gr/operacoes/monitoramento', icon: Radio },
|
||||
// // { id: 'op-telemetria', label: 'Telemetria', path: '/plataforma/gr/operacoes/telemetria', icon: MapPin },
|
||||
// //{ id: 'op-outlook', label: 'Central de Mensagens', path: '/plataforma/gr/operacoes/outlook', icon: Mail },
|
||||
// //{ id: 'op-fiscal', label: 'Suporte Fiscal', path: '/plataforma/gr/operacoes/fiscal', icon: FileCheck },
|
||||
// //{ id: 'op-cte', label: 'Revisão de Cargas', path: '/plataforma/gr/operacoes/cte', icon: Truck },
|
||||
// //{ id: 'op-checklist', label: 'Saúde da Frota', path: '/plataforma/gr/operacoes/checklist', icon: ClipboardCheck },
|
||||
// //{ id: 'op-documentos', label: 'Meus Documentos', path: '/plataforma/gr/operacoes/documentos', icon: FolderOpen },
|
||||
// //{ id: 'op-planilhas', label: 'Controle Operacional', path: '/plataforma/gr/operacoes/planilhas', icon: FileSpreadsheet }
|
||||
// // { id: 'op-outlook', label: 'Central de Mensagens', path: '/plataforma/gr/operacoes/outlook', icon: Mail },
|
||||
// // { id: 'op-fiscal', label: 'Suporte Fiscal', path: '/plataforma/gr/operacoes/fiscal', icon: FileCheck },
|
||||
// // { id: 'op-cte', label: 'Revisão de Cargas', path: '/plataforma/gr/operacoes/cte', icon: Truck },
|
||||
// // { id: 'op-checklist', label: 'Saúde da Frota', path: '/plataforma/gr/operacoes/checklist', icon: ClipboardCheck },
|
||||
// // { id: 'op-documentos', label: 'Meus Documentos', path: '/plataforma/gr/operacoes/documentos', icon: FolderOpen },
|
||||
// // { id: 'op-planilhas', label: 'Controle Operacional', path: '/plataforma/gr/operacoes/planilhas', icon: FileSpreadsheet }
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as dashboardService from '../services/grDashboardService';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Normaliza strings com problemas de encoding (ex: ? no lugar de acentos)
|
||||
*/
|
||||
const normalizeString = (str) => {
|
||||
if (!str || typeof str !== 'string') return str;
|
||||
return str
|
||||
.replace(/N\?O/g, 'NÃO')
|
||||
.replace(/AN\?LISE/g, 'ANÁLISE')
|
||||
.replace(/PEND\?NCIAS/g, 'PENDÊNCIAS')
|
||||
.replace(/REVIS\?O/g, 'REVISÃO')
|
||||
.replace(/D\?VIDA/g, 'DÚVIDA')
|
||||
.replace(/CONTRAT\?RIA/g, 'CONTRATÁRIA')
|
||||
.replace(/LOG\?STICA/g, 'LOGÍSTICA')
|
||||
.replace(/AN\?NCIO/g, 'ANÚNCIO');
|
||||
};
|
||||
|
||||
const normalizeData = (data) => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => {
|
||||
const newItem = { ...item };
|
||||
Object.keys(newItem).forEach(key => {
|
||||
if (typeof newItem[key] === 'string') {
|
||||
newItem[key] = normalizeString(newItem[key]);
|
||||
}
|
||||
});
|
||||
return newItem;
|
||||
});
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
const newData = { ...data };
|
||||
Object.keys(newData).forEach(key => {
|
||||
if (typeof newData[key] === 'string') {
|
||||
newData[key] = normalizeString(newData[key]);
|
||||
}
|
||||
});
|
||||
return newData;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useGrDashboard = () => {
|
||||
const [filters, setFilters] = useState({
|
||||
mes: new Date().getMonth() + 1,
|
||||
ano: new Date().getFullYear(),
|
||||
modo: 'mensal' // 'mensal' ou 'anual'
|
||||
});
|
||||
|
||||
const [data, setData] = useState({
|
||||
// Registros
|
||||
resumo: null,
|
||||
baseEst: [],
|
||||
coordenadorEst: [],
|
||||
despachanteEst: [],
|
||||
baseCoordenadorEst: [],
|
||||
globalStatusEst: [],
|
||||
// Contratos
|
||||
statusContratos: [],
|
||||
producaoBaseContratos: [],
|
||||
coordenadorStatusAssinatura: [],
|
||||
despachanteContratos: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = {
|
||||
ano: filters.ano,
|
||||
...(filters.modo === 'mensal' && { mes: filters.mes })
|
||||
};
|
||||
|
||||
const [
|
||||
resumo,
|
||||
baseEst,
|
||||
coordenadorEst,
|
||||
despachanteEst,
|
||||
baseCoordenadorEst,
|
||||
globalStatusEst,
|
||||
statusContratos,
|
||||
producaoBaseContratos,
|
||||
coordenadorStatusAssinatura,
|
||||
despachanteContratos
|
||||
] = await Promise.all([
|
||||
dashboardService.getResumo(params),
|
||||
dashboardService.getBaseStatistics(params),
|
||||
dashboardService.getCoordenadorStatistics(params),
|
||||
dashboardService.getDespachanteStatistics(params),
|
||||
dashboardService.getBaseCoordenadorStatistics(params),
|
||||
dashboardService.getGlobalStatusStatistics(params),
|
||||
dashboardService.getGlobalStatusContratos(params),
|
||||
dashboardService.getProducaoBaseContratos(params),
|
||||
dashboardService.getCoordenadorStatusAssinatura(params),
|
||||
dashboardService.getDespachanteContratos(params)
|
||||
]);
|
||||
|
||||
setData({
|
||||
resumo: normalizeData(resumo),
|
||||
baseEst: normalizeData(Array.isArray(baseEst) ? baseEst : []),
|
||||
coordenadorEst: normalizeData(Array.isArray(coordenadorEst) ? coordenadorEst : []),
|
||||
despachanteEst: normalizeData(Array.isArray(despachanteEst) ? despachanteEst : []),
|
||||
baseCoordenadorEst: normalizeData(Array.isArray(baseCoordenadorEst) ? baseCoordenadorEst : []),
|
||||
globalStatusEst: normalizeData(Array.isArray(globalStatusEst) ? globalStatusEst : []),
|
||||
statusContratos: normalizeData(Array.isArray(statusContratos) ? statusContratos : []),
|
||||
producaoBaseContratos: normalizeData(Array.isArray(producaoBaseContratos) ? producaoBaseContratos : []),
|
||||
coordenadorStatusAssinatura: normalizeData(Array.isArray(coordenadorStatusAssinatura) ? coordenadorStatusAssinatura : []),
|
||||
despachanteContratos: normalizeData(Array.isArray(despachanteContratos) ? despachanteContratos : [])
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching dashboard data:', err);
|
||||
setError(err);
|
||||
toast.error('Erro ao carregar dados do dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchData
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import api from '@/services/api';
|
||||
|
||||
|
||||
/**
|
||||
* Envia o formulário inicial de dados do motorista e retorna o ID da submissão.
|
||||
* @param {Object} formData Objeto com os campos do formulário
|
||||
|
|
@ -36,3 +37,27 @@ export const uploadDriverFiles = async (submissionId, fileFormData) => {
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Faz o download da planilha de motoristas aprovados.
|
||||
* Dispara o download automaticamente no browser.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const downloadAprovados = async () => {
|
||||
const response = await api.get('/drivers/download-aprovados', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers?.['content-disposition'] ?? '';
|
||||
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=(["']?)([^"'\n]*?)\1(?:;|$)/);
|
||||
const fileName = fileNameMatch?.[2]?.trim() || `motoristas-aprovados-${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import api from '@/services/api';
|
||||
|
||||
/**
|
||||
* Busca o resumo geral do GR.
|
||||
* GET /resumo
|
||||
*/
|
||||
export const getResumo = async (params = {}) => {
|
||||
const response = await api.get('/resumo', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas de base.
|
||||
* GET /base/estatistica
|
||||
*/
|
||||
export const getBaseStatistics = async (params = {}) => {
|
||||
const response = await api.get('/base/estatistica', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas por coordenador.
|
||||
* GET /coordenador/estatistica
|
||||
*/
|
||||
export const getCoordenadorStatistics = async (params = {}) => {
|
||||
const response = await api.get('/coordenador/estatistica', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas por despachante.
|
||||
* GET /despachante/estatisticas
|
||||
*/
|
||||
export const getDespachanteStatistics = async (params = {}) => {
|
||||
const response = await api.get('/despachante/estatisticas', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas de base/coordenador.
|
||||
* GET /base_coordenador/estatisticas
|
||||
*/
|
||||
export const getBaseCoordenadorStatistics = async (params = {}) => {
|
||||
const response = await api.get('/base_coordenador/estatisticas', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas de status global.
|
||||
* GET /global_status/estatistica
|
||||
*/
|
||||
export const getGlobalStatusStatistics = async (params = {}) => {
|
||||
const response = await api.get('/global_status/estatistica', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas de status global para contratos.
|
||||
* GET /global_status/contratos
|
||||
*/
|
||||
export const getGlobalStatusContratos = async (params = {}) => {
|
||||
const response = await api.get('/global_status/contratos', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas de produção por base para contratos.
|
||||
* GET /producao_base/contratos
|
||||
*/
|
||||
export const getProducaoBaseContratos = async (params = {}) => {
|
||||
const response = await api.get('/producao_base/contratos', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas de status de assinatura por coordenador.
|
||||
* GET /coordenador/status-assinatura
|
||||
*/
|
||||
export const getCoordenadorStatusAssinatura = async (params = {}) => {
|
||||
const response = await api.get('/coordenador/status-assinatura', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca estatísticas de contratos por despachante.
|
||||
* GET /despachante/contratos
|
||||
*/
|
||||
export const getDespachanteContratos = async (params = {}) => {
|
||||
const response = await api.get('/despachante/contratos', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -193,6 +193,22 @@ const ContractsView = () => {
|
|||
...payload,
|
||||
iddrivers: formData.iddrivers
|
||||
});
|
||||
|
||||
// Garantir que os status sejam aplicados usando as rotas adequadas
|
||||
if (payload.global_status) {
|
||||
await grService.updateContractGlobalStatus({
|
||||
iddrivers: formData.iddrivers,
|
||||
global_status: payload.global_status
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.status_assinatura) {
|
||||
await grService.updateContractSignatureStatus({
|
||||
iddrivers: formData.iddrivers,
|
||||
status_assinatura: payload.status_assinatura
|
||||
});
|
||||
}
|
||||
|
||||
toast.success("Contrato criado!");
|
||||
}
|
||||
setIsSidebarOpen(false);
|
||||
|
|
@ -599,6 +615,22 @@ const ContractsView = () => {
|
|||
delete payload.status_assinatura_form;
|
||||
|
||||
await grService.createContract(payload);
|
||||
|
||||
// Garantir que os status sejam aplicados usando as rotas adequadas
|
||||
if (payload.global_status) {
|
||||
await grService.updateContractGlobalStatus({
|
||||
iddrivers: payload.iddrivers,
|
||||
global_status: payload.global_status
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.status_assinatura) {
|
||||
await grService.updateContractSignatureStatus({
|
||||
iddrivers: payload.iddrivers,
|
||||
status_assinatura: payload.status_assinatura
|
||||
});
|
||||
}
|
||||
|
||||
toast.success("Contrato criado com sucesso!");
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,485 @@
|
|||
import React, { lazy, Suspense } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useGrDashboard } from '../hooks/useGrDashboard';
|
||||
import {
|
||||
Users,
|
||||
MapPin,
|
||||
BarChart3,
|
||||
PieChart as PieChartIcon,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
LayoutDashboard,
|
||||
UserCheck,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
FileCheck,
|
||||
ClipboardList
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend
|
||||
} from 'recharts';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { GrPeriodSelector } from '../components/GrPeriodSelector';
|
||||
|
||||
// Lazy load StatisticsView for heavy data visualization
|
||||
const StatisticsView = lazy(() => import('../../prafrot/views/StatisticsView'));
|
||||
// Cores premium consistentes com o design system
|
||||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#14b8a6'];
|
||||
|
||||
const Skeleton = ({ className }) => (
|
||||
<div className={`animate-pulse bg-slate-200 dark:bg-slate-800 rounded-xl ${className}`} />
|
||||
);
|
||||
|
||||
const KPIWrapper = ({ title, value, icon: Icon, color, loading }) => (
|
||||
<Card className="border-none shadow-sm hover:shadow-md transition-all bg-white dark:bg-[#1c1c1c] overflow-hidden group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-[0.15em]">{title}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<h3 className="text-[clamp(1.25rem,2.5vw,1.75rem)] font-bold text-slate-800 dark:text-white group-hover:text-[var(--gr-primary)] transition-colors">
|
||||
{value ?? 0}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-2xl ${color} shadow-inner group-hover:scale-110 transition-transform flex items-center justify-center shrink-0`}>
|
||||
{React.cloneElement(Icon, { size: 24 })}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white/90 dark:bg-[#1c1c1c]/90 border border-slate-200 dark:border-[#2a2a2a] p-3 rounded-xl shadow-xl backdrop-blur-md z-[9999]">
|
||||
{label && <p className="text-[10px] font-bold uppercase text-slate-400 mb-2 tracking-widest">{label}</p>}
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color || item.fill }} />
|
||||
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">{item.name}:</span>
|
||||
<span className="text-xs font-bold text-slate-900 dark:text-white">{item.value.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GrDashboardView = () => {
|
||||
return (
|
||||
<div className="w-full h-full min-h-[calc(100vh-100px)] relative bg-white dark:bg-[#1b1b1b] animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<Suspense fallback={
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--gr-primary)]" size={48} />
|
||||
const { data, loading, refresh, filters, setFilters } = useGrDashboard();
|
||||
const [activeTab, setActiveTab] = useState("registros");
|
||||
|
||||
// Dados formatados para Registros
|
||||
const statusData = useMemo(() => data.globalStatusEst.map(item => ({
|
||||
name: item.global_status || 'N/A',
|
||||
value: item.total || 0
|
||||
})), [data.globalStatusEst]);
|
||||
|
||||
const baseData = useMemo(() => data.baseEst.map(item => ({
|
||||
name: item.base || 'N/A',
|
||||
total: item.total || 0
|
||||
})).slice(0, 8), [data.baseEst]);
|
||||
|
||||
const coordData = useMemo(() => data.coordenadorEst.map(item => ({
|
||||
name: item.coordenador || 'N/A',
|
||||
total: item.total || 0
|
||||
})).slice(0, 10), [data.coordenadorEst]);
|
||||
|
||||
// Dados formatados para Contratos
|
||||
const statusContratosData = useMemo(() => data.statusContratos.map(item => ({
|
||||
name: item.status_assinatura || item.global_status || 'N/A',
|
||||
value: item.total || 0
|
||||
})), [data.statusContratos]);
|
||||
|
||||
const producaoBaseContratosData = useMemo(() => data.producaoBaseContratos.map(item => ({
|
||||
name: item.base || 'N/A',
|
||||
total: item.total_contratos || item.total || 0,
|
||||
total_dia: item.total_dia || 0
|
||||
})).slice(0, 8), [data.producaoBaseContratos]);
|
||||
|
||||
const despachanteContratosData = useMemo(() => data.despachanteContratos.map(item => ({
|
||||
name: item.despachante || 'N/A',
|
||||
total: item.total_contratos || 0
|
||||
})).slice(0, 8), [data.despachanteContratos]);
|
||||
|
||||
const totalRegistros = useMemo(() => statusData.reduce((acc, curr) => acc + curr.value, 0), [statusData]);
|
||||
const totalContratosArr = useMemo(() => statusContratosData.reduce((acc, curr) => acc + curr.value, 0), [statusContratosData]);
|
||||
const totalContratos = totalContratosArr > 0 ? totalContratosArr : data.statusContratos.reduce((acc, curr) => acc + (curr.total || 0), 0);
|
||||
|
||||
if (loading && !data.resumo) {
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-8 animate-in fade-in duration-500">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
}>
|
||||
<StatisticsView viewMode="embedded" />
|
||||
</Suspense>
|
||||
<Skeleton className="h-10 w-40" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 w-full h-full min-h-[calc(100vh-100px)] relative bg-[#f8fafc] dark:bg-[#0f0f0f] animate-in fade-in slide-in-from-bottom-4 duration-700 pb-12 overflow-y-auto custom-scrollbar">
|
||||
{/* Header com Tipografia Fluida */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-[var(--gr-primary)]/10 rounded-xl">
|
||||
<LayoutDashboard className="text-[var(--gr-primary)]" size={28} />
|
||||
</div>
|
||||
<h1 className="text-[clamp(1.5rem,3.5vw,2.25rem)] font-bold text-slate-800 dark:text-white tracking-tight">
|
||||
Cockpit <span className="text-[var(--gr-primary)]">Estatístico</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-[clamp(0.875rem,1.2vw,1rem)] text-slate-500 dark:text-slate-400 font-medium">
|
||||
Monitoramento analítico de cadastros e conformidade contratual.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
||||
<GrPeriodSelector filters={filters} setFilters={setFilters} loading={loading} />
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 rounded-2xl bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-600 dark:text-slate-300 font-bold text-sm hover:shadow-xl hover:-translate-y-0.5 active:scale-95 transition-all w-full md:w-fit"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? "animate-spin" : ""} />
|
||||
Sincronizar Dados
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="registros" className="space-y-8" onValueChange={setActiveTab}>
|
||||
<div className="flex justify-center md:justify-start">
|
||||
<TabsList className="bg-slate-100 dark:bg-[#1c1c1c] p-1.5 rounded-2xl border border-slate-200 dark:border-[#2a2a2a]">
|
||||
<TabsTrigger value="registros" className="rounded-xl px-8 py-2.5 text-xs font-bold uppercase tracking-widest data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2a] data-[state=active]:text-[var(--gr-primary)] data-[state=active]:shadow-sm transition-all">
|
||||
Gestão de Cadastros
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="contratos" className="rounded-xl px-8 py-2.5 text-xs font-bold uppercase tracking-widest data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2a] data-[state=active]:text-[var(--gr-primary)] data-[state=active]:shadow-sm transition-all">
|
||||
Gestão de Contratos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="registros" className="space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
|
||||
{/* KPIs Registros */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<KPIWrapper
|
||||
title="Total de Cadastros"
|
||||
value={totalRegistros}
|
||||
icon={<Activity />}
|
||||
color="bg-blue-500/10 text-blue-600"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPIWrapper
|
||||
title="Aprovados"
|
||||
value={statusData.find(s => s.name.includes('APROVADO'))?.value || 0}
|
||||
icon={<ShieldCheck />}
|
||||
color="bg-emerald-500/10 text-emerald-600"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPIWrapper
|
||||
title="Pedências / Revisão"
|
||||
value={statusData.find(s => s.name.includes('PEND'))?.value || 0}
|
||||
icon={<AlertCircle />}
|
||||
color="bg-orange-500/10 text-orange-600"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPIWrapper
|
||||
title="Usuários Plataforma"
|
||||
value={data.resumo?.total_usuarios || 0}
|
||||
icon={<Users />}
|
||||
color="bg-purple-500/10 text-purple-600"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Graficos Registros */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 border-b dark:border-[#2a2a2a] mb-6 p-6">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-xl text-emerald-600">
|
||||
<PieChartIcon size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-700 dark:text-slate-300">Status dos Cadastros</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[380px] p-0 pb-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
innerRadius="60%"
|
||||
outerRadius="85%"
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
layout="horizontal"
|
||||
iconType="circle"
|
||||
wrapperStyle={{ paddingTop: '20px', fontSize: '10px', fontWeight: 'bold' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 border-b dark:border-[#2a2a2a] mb-6 p-6">
|
||||
<div className="p-2 bg-blue-500/10 rounded-xl text-blue-600">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-700 dark:text-slate-300">Volume por Base Operacional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[380px] p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={baseData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
fontSize={10}
|
||||
fontWeight="600"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'currentColor' }}
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<YAxis
|
||||
fontSize={10}
|
||||
fontWeight="600"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'currentColor' }}
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
<Bar dataKey="total" name="Cadastros" fill="#3b82f6" radius={[6, 6, 0, 0]} barSize={32} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 border-b dark:border-[#2a2a2a] mb-6 p-6">
|
||||
<div className="p-2 bg-purple-500/10 rounded-xl text-purple-600">
|
||||
<UserCheck size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-700 dark:text-slate-300">Produção por Coordenador</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[450px] p-4 pb-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={coordData} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#f1f5f9" />
|
||||
<XAxis type="number" fontSize={10} fontWeight="600" axisLine={false} tickLine={false} className="text-slate-400" />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
fontSize={10}
|
||||
fontWeight="700"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={120}
|
||||
className="text-slate-600 dark:text-slate-300"
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(139, 92, 246, 0.05)' }} />
|
||||
<Bar dataKey="total" name="Cadastros Ativos" fill="#8b5cf6" radius={[0, 6, 6, 0]} barSize={24} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contratos" className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
{/* KPIs Contratos */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<KPIWrapper
|
||||
title="Total de Contratos"
|
||||
value={totalContratos}
|
||||
icon={<FileCheck />}
|
||||
color="bg-emerald-500/10 text-emerald-600"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPIWrapper
|
||||
title="Assinados"
|
||||
value={statusContratosData.find(s => s.name.includes('ASSINADO'))?.value || 0}
|
||||
icon={<ShieldCheck />}
|
||||
color="bg-blue-500/10 text-blue-600"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPIWrapper
|
||||
title="Pedentes Assinatura"
|
||||
value={statusContratosData.find(s => s.name.includes('PEND'))?.value || 0}
|
||||
icon={<ClipboardList />}
|
||||
color="bg-orange-500/10 text-orange-600"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPIWrapper
|
||||
title="Produção Diária"
|
||||
value={data.producaoBaseContratos.reduce((acc, curr) => acc + (curr.total_dia || 0), 0)}
|
||||
icon={<TrendingUp />}
|
||||
color="bg-purple-500/10 text-purple-600"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 border-b dark:border-[#2a2a2a] mb-6 p-6">
|
||||
<div className="p-2 bg-blue-500/10 rounded-xl text-blue-600">
|
||||
<PieChartIcon size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-700 dark:text-slate-300">Status de Assinatura</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[380px] p-0 pb-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusContratosData}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
innerRadius="60%"
|
||||
outerRadius="85%"
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{statusContratosData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
layout="horizontal"
|
||||
iconType="circle"
|
||||
wrapperStyle={{ paddingTop: '20px', fontSize: '10px', fontWeight: 'bold' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 border-b dark:border-[#2a2a2a] mb-6 p-6">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-xl text-emerald-600">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-700 dark:text-slate-300">Contratos por Base</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[380px] p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={producaoBaseContratosData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
fontSize={10}
|
||||
fontWeight="600"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'currentColor' }}
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<YAxis
|
||||
fontSize={10}
|
||||
fontWeight="600"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'currentColor' }}
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(16, 185, 129, 0.05)' }} />
|
||||
<Bar dataKey="total" name="Total Contratos" fill="#10b981" radius={[6, 6, 0, 0]} barSize={32} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 border-b dark:border-[#2a2a2a] mb-6 p-6">
|
||||
<div className="p-2 bg-blue-500/10 rounded-xl text-blue-600">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-700 dark:text-slate-300">Contratos por Despachante</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[400px] p-4 pb-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={despachanteContratosData} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#f1f5f9" />
|
||||
<XAxis type="number" fontSize={10} fontWeight="600" axisLine={false} tickLine={false} className="text-slate-400" />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
fontSize={10}
|
||||
fontWeight="700"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={120}
|
||||
className="text-slate-600 dark:text-slate-300"
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(59, 130, 246, 0.05)' }} />
|
||||
<Bar dataKey="total" name="Contratos" fill="#3b82f6" radius={[0, 6, 6, 0]} barSize={24} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 border-b dark:border-[#2a2a2a] mb-6 p-6">
|
||||
<div className="p-2 bg-purple-500/10 rounded-xl text-purple-600">
|
||||
<ClipboardList size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-700 dark:text-slate-300">Status por Coordenador</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[450px] p-4 pb-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.coordenadorStatusAssinatura.slice(0, 10)} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#f1f5f9" />
|
||||
<XAxis type="number" fontSize={10} fontWeight="600" axisLine={false} tickLine={false} className="text-slate-400" />
|
||||
<YAxis
|
||||
dataKey="coordenador"
|
||||
type="category"
|
||||
fontSize={10}
|
||||
fontWeight="700"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={120}
|
||||
className="text-slate-600 dark:text-slate-300"
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(139, 92, 246, 0.05)' }} />
|
||||
<Legend verticalAlign="top" align="right" iconType="circle" wrapperStyle={{ paddingBottom: '20px', fontSize: '10px', fontWeight: 'bold' }} />
|
||||
<Bar dataKey="ASSINADO" name="Assinado" stackId="a" fill="#10b981" barSize={24} />
|
||||
<Bar dataKey="NÃO ASSINADO" name="Não Assinado" stackId="a" fill="#f59e0b" barSize={24} />
|
||||
<Bar dataKey="SEM ASSINATURA" name="Sem Assinatura" stackId="a" fill="#ef4444" barSize={24} />
|
||||
<Bar dataKey="EM ANDAMENTO" name="Em Andamento" stackId="a" fill="#3b82f6" barSize={24} radius={[0, 6, 6, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, lazy, Suspense } from 'react';
|
||||
import { UserPlus, Loader2 } from 'lucide-react';
|
||||
import { UserPlus, Loader2, Download } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { downloadAprovados } from '../services/driverService';
|
||||
|
||||
|
||||
// Lazy loading the simplified features to improve initial performance
|
||||
const VehiclesView = lazy(() => import('../components/GrVehiclesSimplified'));
|
||||
|
|
@ -8,6 +10,19 @@ const RegistrationsView = lazy(() => import('../components/GrRegistrationsSimpli
|
|||
|
||||
const GrOpsCadastrosView = () => {
|
||||
const [activeTab, setActiveTab] = useState('veiculos');
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadAprovados();
|
||||
} catch (err) {
|
||||
console.error('[GrOpsCadastrosView] Erro ao exportar motoristas aprovados:', err);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 p-6 md:p-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
|
|
@ -20,6 +35,24 @@ const GrOpsCadastrosView = () => {
|
|||
Gestão unificada de frotas e parceiros logísticos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Botão de Exportação de Motoristas Aprovados */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isDownloading}
|
||||
className="
|
||||
inline-flex items-center gap-2 px-5 py-2.5
|
||||
bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
text-white text-xs font-bold uppercase tracking-widest
|
||||
rounded-2xl shadow-md transition-all duration-200
|
||||
"
|
||||
>
|
||||
{isDownloading
|
||||
? <Loader2 size={16} className="animate-spin" />
|
||||
: <Download size={16} />}
|
||||
{isDownloading ? 'Exportando...' : 'Exportar Aprovados'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Tabs defaultValue="veiculos" value={activeTab} onValueChange={setActiveTab} className="w-full space-y-8">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Plus, Filter, X, Loader2, Archive } from 'lucide-react';
|
||||
import { Search, Plus, Filter, X, Loader2, Archive, Download } from 'lucide-react';
|
||||
import { downloadAprovados } from '../services/driverService';
|
||||
import { toast } from 'sonner';
|
||||
import { handleGrError } from '../utils/grErrorHandler';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
|
|
@ -21,6 +22,18 @@ const RegistrationsView = () => {
|
|||
const [data, setData] = useState([]);
|
||||
const [bases, setBases] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadAprovados();
|
||||
} catch (err) {
|
||||
console.error('[RegistrationsView] Erro ao exportar aprovados:', err);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
const [contracts, setContracts] = useState([]);
|
||||
const [allContracts, setAllContracts] = useState([]); // Todos os contratos para verificar bloqueio
|
||||
|
||||
|
|
@ -496,6 +509,19 @@ const RegistrationsView = () => {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* Exportar Aprovados */}
|
||||
{userGroup !== 'Despachante' && userGroup !== 'Coordenador' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={isDownloading}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed text-white rounded-[24px] font-bold text-[11px] uppercase tracking-widest transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
{isDownloading ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||
{isDownloading ? 'Exportando...' : 'Exportar Aprovados'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
{userGroup !== 'Documentação' && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,126 +1,183 @@
|
|||
import React from 'react';
|
||||
import { X, Filter, Trash2, Check, ChevronDown } from 'lucide-react';
|
||||
import { X, Filter, Check, ChevronDown } from 'lucide-react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
/**
|
||||
* Modal de Filtros Avançados — adaptado ao dark mode e paleta indigo do ambiente RH.
|
||||
*/
|
||||
const AdvancedFiltersModal = ({ isOpen, onClose, onApply, options = {}, initialFilters = {}, config = [] }) => {
|
||||
const [filters, setFilters] = React.useState(initialFilters);
|
||||
|
||||
// Sync with parent when modal opens or initialFilters change
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFilters(initialFilters);
|
||||
}
|
||||
if (isOpen) setFilters(initialFilters);
|
||||
}, [isOpen, initialFilters]);
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(filters);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFilters({});
|
||||
};
|
||||
const handleChange = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
|
||||
const handleApply = () => { onApply(filters); };
|
||||
const handleClear = () => setFilters({});
|
||||
|
||||
const activeCount = Object.values(filters).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={isOpen} onOpenChange={onClose}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/70 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content className="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-0 bg-white shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-2xl md:w-full">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Filter className="w-5 h-5 text-gray-500 fill-gray-500" />
|
||||
|
||||
{/* Overlay */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/60 dark:bg-black/75 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
|
||||
{/* Panel */}
|
||||
<DialogPrimitive.Content className="
|
||||
fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2
|
||||
bg-white dark:bg-[#161616]
|
||||
border border-slate-200 dark:border-white/[0.07]
|
||||
rounded-2xl shadow-2xl shadow-black/20 dark:shadow-black/50
|
||||
duration-200
|
||||
data-[state=open]:animate-in data-[state=closed]:animate-out
|
||||
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
||||
data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]
|
||||
data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]
|
||||
">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-white/[0.07]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-indigo-500/10 dark:bg-indigo-500/15 flex items-center justify-center">
|
||||
<Filter className="w-4 h-4 text-indigo-500" />
|
||||
</div>
|
||||
<DialogPrimitive.Title asChild>
|
||||
<h2 className="text-lg font-bold text-gray-800">Filtros Avançados</h2>
|
||||
<h2 className="text-sm font-bold text-slate-800 dark:text-white tracking-tight">
|
||||
Filtros Avançados
|
||||
</h2>
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Selecione os filtros detalhados para refinar a busca.
|
||||
</DialogPrimitive.Description>
|
||||
{activeCount > 0 && (
|
||||
<span className="bg-blue-500 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
{activeCount} filtros ativos
|
||||
<span className="bg-indigo-500 text-white text-[10px] font-bold px-2.5 py-0.5 rounded-full">
|
||||
{activeCount} ativo{activeCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1 text-red-500 hover:text-red-700 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span className="text-xs">☰</span> Limpar
|
||||
</button>
|
||||
<DialogPrimitive.Close className="rounded-full p-1 hover:bg-gray-100 transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-[11px] font-semibold text-slate-400 dark:text-slate-500 hover:text-rose-500 dark:hover:text-rose-400 transition-colors uppercase tracking-wider"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
<DialogPrimitive.Close className="w-7 h-7 rounded-lg flex items-center justify-center hover:bg-slate-100 dark:hover:bg-white/10 transition-colors">
|
||||
<X className="w-4 h-4 text-slate-400 dark:text-slate-500" />
|
||||
<span className="sr-only">Fechar</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body - Scrollable Form */}
|
||||
<div className="px-6 py-6 max-h-[60vh] overflow-y-auto space-y-4">
|
||||
{config.map((filter) => {
|
||||
if (filter.type === 'select') {
|
||||
return (
|
||||
<div key={filter.field} className="relative">
|
||||
<div className="relative w-full">
|
||||
<select
|
||||
className="w-full appearance-none border border-gray-200 rounded-xl py-3 pl-3 pr-8 bg-white hover:border-gray-300 transition-colors text-left text-sm text-gray-700 font-medium focus:outline-none focus:border-blue-500"
|
||||
value={filters[filter.field] || ''}
|
||||
onChange={(e) => handleChange(filter.field, e.target.value)}
|
||||
>
|
||||
<option value="" className="text-gray-400">{filter.label}</option>
|
||||
{options[filter.field]?.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{/* ── Body ── */}
|
||||
<div className="px-5 py-5 max-h-[60vh] overflow-y-auto space-y-4">
|
||||
{config.length === 0 && (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 text-center py-4 italic">
|
||||
Nenhum filtro disponível.
|
||||
</p>
|
||||
)}
|
||||
|
||||
if (filter.type === 'text') {
|
||||
return (
|
||||
<div key={filter.field} className="relative group">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-700 font-medium text-sm">{filter.label}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={filter.placeholder}
|
||||
className="w-full border border-gray-200 rounded-xl py-3 pl-[var(--padding-left)] pr-3 text-sm focus:outline-none focus:border-blue-500 placeholder:text-gray-400 transition-colors"
|
||||
style={{ '--padding-left': `${filter.label.length * 8 + 24}px` }} // dynamic padding approx
|
||||
value={filters[filter.field] || ''}
|
||||
onChange={(e) => handleChange(filter.field, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{config.map((filter) => {
|
||||
if (filter.type === 'select') {
|
||||
return (
|
||||
<div key={filter.field} className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">
|
||||
{filter.label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="
|
||||
w-full appearance-none
|
||||
bg-slate-50 dark:bg-[#1f1f1f]
|
||||
border border-slate-200 dark:border-white/[0.08]
|
||||
hover:border-indigo-400 dark:hover:border-indigo-500/60
|
||||
focus:border-indigo-500 dark:focus:border-indigo-500
|
||||
focus:ring-2 focus:ring-indigo-500/15
|
||||
text-slate-700 dark:text-slate-200
|
||||
rounded-xl py-2.5 pl-4 pr-9
|
||||
text-xs font-medium
|
||||
transition-all outline-none
|
||||
"
|
||||
value={filters[filter.field] || ''}
|
||||
onChange={(e) => handleChange(filter.field, e.target.value)}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{options[filter.field]?.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400 dark:text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.type === 'text') {
|
||||
return (
|
||||
<div key={filter.field} className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">
|
||||
{filter.label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={filter.placeholder || `Buscar por ${filter.label}...`}
|
||||
className="
|
||||
w-full
|
||||
bg-slate-50 dark:bg-[#1f1f1f]
|
||||
border border-slate-200 dark:border-white/[0.08]
|
||||
hover:border-indigo-400 dark:hover:border-indigo-500/60
|
||||
focus:border-indigo-500 dark:focus:border-indigo-500
|
||||
focus:ring-2 focus:ring-indigo-500/15
|
||||
text-slate-700 dark:text-slate-200
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-600
|
||||
rounded-xl py-2.5 px-4 text-xs
|
||||
transition-all outline-none
|
||||
"
|
||||
value={filters[filter.field] || ''}
|
||||
onChange={(e) => handleChange(filter.field, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-white rounded-b-2xl">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-end gap-2.5 px-5 py-4 border-t border-slate-100 dark:border-white/[0.07] rounded-b-2xl bg-slate-50/50 dark:bg-[#111111]/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="
|
||||
px-4 py-2 text-xs font-semibold
|
||||
text-slate-600 dark:text-slate-400
|
||||
bg-white dark:bg-white/5
|
||||
border border-slate-200 dark:border-white/[0.08]
|
||||
hover:border-slate-300 dark:hover:border-white/20
|
||||
rounded-xl transition-all
|
||||
"
|
||||
>
|
||||
Cancelar
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="px-4 py-2 text-sm font-bold text-gray-900 bg-[#fbbf24] hover:bg-[#f59e0b] rounded-xl flex items-center gap-2 transition-colors shadow-sm"
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="
|
||||
px-5 py-2 text-xs font-bold
|
||||
text-white bg-indigo-600 hover:bg-indigo-700
|
||||
dark:bg-indigo-500 dark:hover:bg-indigo-600
|
||||
rounded-xl flex items-center gap-1.5
|
||||
transition-all shadow-lg shadow-indigo-500/20
|
||||
active:scale-95
|
||||
"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Aplicar Filtros
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
Aplicar Filtros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
CheckCircle2, Wallet, Plus, Minus, Power, PowerOff,
|
||||
ShieldCheck, Smartphone, Laptop, Globe, HardDrive,
|
||||
Folder, UserCheck, UserMinus, Hash, Fingerprint, Clock,
|
||||
RefreshCw, UserCircle
|
||||
RefreshCw, UserCircle, ClipboardList
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -26,6 +26,8 @@ import { useBeneficios } from '../hooks/useBeneficios';
|
|||
import { useRecisao } from '../hooks/useRecisao';
|
||||
import { usePagamento } from '../hooks/usePagamento';
|
||||
import { useRhFeedback } from './RhFeedbackNotification';
|
||||
import { ExperienceContractFormSidebar } from './ExperienceContractFormSidebar';
|
||||
import { useExperienceContracts } from '../hooks/useExperienceContracts';
|
||||
|
||||
export const EmployeeDetailPanel = ({ employee: initialEmployee, onClose, onEdit, onUpdate }) => {
|
||||
const { fetchEmployeeById, inactivateEmployee, reactivateEmployee } = useEmployees();
|
||||
|
|
@ -37,6 +39,7 @@ export const EmployeeDetailPanel = ({ employee: initialEmployee, onClose, onEdit
|
|||
const { recisoes, fetchRecisoesByColaborador, salvarRecisao, loading: recisaoLoading } = useRecisao();
|
||||
const { pagamento, fetchPagamentoByColaborador, loading: pagamentoLoading } = usePagamento();
|
||||
const { success, error, handleBackendError } = useRhFeedback();
|
||||
const { handleCreateContract, rawContracts, fetchContracts } = useExperienceContracts();
|
||||
|
||||
const [employee, setEmployee] = useState(initialEmployee);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -44,6 +47,7 @@ export const EmployeeDetailPanel = ({ employee: initialEmployee, onClose, onEdit
|
|||
const [calculosResult, setCalculosResult] = useState(null);
|
||||
const [savingCalculo, setSavingCalculo] = useState(false);
|
||||
const [activeDpTab, setActiveDpTab] = useState('calculos');
|
||||
const [isExperienceFormOpen, setIsExperienceFormOpen] = useState(false);
|
||||
|
||||
// Estados para Simulação de Folha
|
||||
const [config, setConfig] = useState({
|
||||
|
|
@ -84,7 +88,14 @@ export const EmployeeDetailPanel = ({ employee: initialEmployee, onClose, onEdit
|
|||
}
|
||||
};
|
||||
loadFullData();
|
||||
}, [initialEmployee, fetchEmployeeById]);
|
||||
// Carregar contratos de experiência para validar visibilidade de botões
|
||||
fetchContracts();
|
||||
}, [initialEmployee, fetchEmployeeById, fetchContracts]);
|
||||
|
||||
// Verificar se já possui contrato de experiência
|
||||
const hasExperienceContract = useMemo(() => {
|
||||
return rawContracts.some(c => Number(c.idcolaborador) === Number(employee?.id));
|
||||
}, [rawContracts, employee?.id]);
|
||||
|
||||
// Carregar cálculos salvos ao mudar mês/ano ou ao abrir a aba
|
||||
useEffect(() => {
|
||||
|
|
@ -366,6 +377,18 @@ export const EmployeeDetailPanel = ({ employee: initialEmployee, onClose, onEdit
|
|||
<span className="hidden sm:inline">Editar Registro</span>
|
||||
<span className="sm:hidden">Editar</span>
|
||||
</Button>
|
||||
{!hasExperienceContract && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsExperienceFormOpen(true)}
|
||||
className="rounded-xl h-10 sm:h-11 px-4 sm:px-6 text-[clamp(1rem,1.1vw,1.0625rem)] font-semibold uppercase tracking-widest gap-2 bg-white dark:bg-transparent border-indigo-200 dark:border-indigo-500/20 text-indigo-600 hover:bg-indigo-500/5 transition-all flex-1 sm:flex-initial"
|
||||
>
|
||||
<ClipboardList size={12} className="sm:w-3.5 sm:h-3.5" />
|
||||
<span className="hidden sm:inline">Iniciar Experiência</span>
|
||||
<span className="sm:hidden">Experiência</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -1310,6 +1333,30 @@ export const EmployeeDetailPanel = ({ employee: initialEmployee, onClose, onEdit
|
|||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Formulário de Contrato de Experiência - pré-populado com dados do colaborador */}
|
||||
<ExperienceContractFormSidebar
|
||||
isOpen={isExperienceFormOpen}
|
||||
onClose={() => setIsExperienceFormOpen(false)}
|
||||
onSubmit={async (data) => {
|
||||
const success = await handleCreateContract({
|
||||
...data,
|
||||
idcolaborador: employee?.id || employee?.idcolaborador,
|
||||
empresa: data.empresa || employee?.empresas || employee?.empresa,
|
||||
data_admissao: data.data_admissao || employee?.data_admissao,
|
||||
global_status: 'Admissão'
|
||||
});
|
||||
if (success) setIsExperienceFormOpen(false);
|
||||
}}
|
||||
initialData={null}
|
||||
prefillData={{
|
||||
idcolaborador: employee?.id || employee?.idcolaborador,
|
||||
empresa: employee?.empresas || '',
|
||||
data_admissao: employee?.data_admissao
|
||||
? new Date(employee.data_admissao).toISOString().split('T')[0]
|
||||
: ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ const ExcelTable = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-[#1b1b1b] border border-slate-200 dark:border-[#2a2a2a] rounded-2xl flex flex-col h-full w-full max-w-full min-w-0 text-[clamp(1rem,1.2vw,1.125rem)] font-sans antialiased text-slate-700 dark:text-[#e0e0e0] relative transition-colors" style={{ height: '100%', maxHeight: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div className="bg-white dark:bg-[#1b1b1b] border border-slate-200 dark:border-[#2a2a2a] rounded-2xl flex flex-col h-full w-full max-w-full min-w-0 text-[13px] font-sans antialiased text-slate-700 dark:text-[#e0e0e0] relative transition-colors" style={{ height: '100%', maxHeight: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{loading && (
|
||||
|
|
@ -134,7 +134,7 @@ const ExcelTable = ({
|
|||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-emerald-500 font-semibold uppercase tracking-widest text-[clamp(1rem,1.2vw,1.125rem)] animate-pulse">Carregando Dados...</span>
|
||||
<span className="text-emerald-500 font-semibold uppercase tracking-widest text-xs animate-pulse">Carregando Dados...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -154,7 +154,7 @@ const ExcelTable = ({
|
|||
}`}
|
||||
>
|
||||
<Filter size={14} strokeWidth={2.5} />
|
||||
<span className="uppercase tracking-wide text-[clamp(1rem,1.2vw,1.125rem)]">
|
||||
<span className="uppercase tracking-wide text-[11px]">
|
||||
{Object.keys(filters).length > 0 ? `${Object.keys(filters).length} Filtros` : 'Filtros Avançados'}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -205,7 +205,7 @@ const ExcelTable = ({
|
|||
}
|
||||
`}</style>
|
||||
|
||||
<table className="border-collapse table-fixed custom-scrollbar w-full min-w-full">
|
||||
<table className="border-collapse table-fixed custom-scrollbar w-full" style={{ minWidth: '100%', width: '100%' }}>
|
||||
<thead className="sticky top-0 z-20 bg-slate-50 dark:bg-[#1b1b1b] shadow-lg shadow-black/5 dark:shadow-black/20 rounded-t-2xl">
|
||||
<tr className="border-b border-slate-200 dark:border-[#333]">
|
||||
{/* Checkbox Header */}
|
||||
|
|
@ -238,9 +238,9 @@ const ExcelTable = ({
|
|||
>
|
||||
<div className="flex items-center justify-between h-full w-full">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`uppercase font-semibold tracking-wider text-[clamp(1rem,1.2vw,1.125rem)] ${sortConfig.key === col.field ? 'text-emerald-600 dark:text-emerald-500' : 'text-slate-500 dark:text-[#e0e0e0]'}`}>
|
||||
{col.header}
|
||||
</span>
|
||||
<span className={`uppercase font-semibold tracking-wider text-[11px] ${sortConfig.key === col.field ? 'text-emerald-600 dark:text-emerald-500' : 'text-slate-500 dark:text-[#e0e0e0]'}`}>
|
||||
{col.header}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sort/Menu Icons */}
|
||||
|
|
@ -258,8 +258,6 @@ const ExcelTable = ({
|
|||
<div className="absolute right-0 top-0 bottom-0 w-[4px] cursor-col-resize hover:bg-emerald-500/50 z-10 translate-x-1/2" onClick={(e) => e.stopPropagation()} />
|
||||
</th>
|
||||
))}
|
||||
{/* Spacer for scrollbar */}
|
||||
<th className="bg-slate-50 dark:bg-[#1b1b1b] border-b border-slate-200 dark:border-[#333]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
|
@ -271,7 +269,7 @@ const ExcelTable = ({
|
|||
<tr
|
||||
key={idx}
|
||||
onClick={() => onRowClick && onRowClick(row)}
|
||||
className={`h-[clamp(40px,5vh,60px)] border-b border-slate-100 dark:border-[#252525] hover:bg-slate-50 dark:hover:bg-[#202020] transition-colors group text-[clamp(1rem,1.2vw,1.125rem)] ${onRowClick ? 'cursor-pointer' : ''} ${idx % 2 === 0 ? 'bg-white dark:bg-[#151515]' : 'bg-slate-50/50 dark:bg-[#181818]'} ${idx === 0 ? '!bg-slate-100 dark:!bg-[#2b2b2b]' : ''} ${isLastRow ? 'last:border-b-0' : ''}`}
|
||||
className={`h-[clamp(56px,6.5vh,72px)] border-b border-slate-100 dark:border-[#252525] hover:bg-slate-50 dark:hover:bg-[#202020] transition-colors group text-[13px] ${onRowClick ? 'cursor-pointer' : ''} ${idx % 2 === 0 ? 'bg-white dark:bg-[#151515]' : 'bg-slate-50/50 dark:bg-[#181818]'} ${idx === 0 ? '!bg-slate-100 dark:!bg-[#2b2b2b]' : ''} ${isLastRow ? 'last:border-b-0' : ''}`}
|
||||
>
|
||||
|
||||
{/* Checkbox Cell */}
|
||||
|
|
@ -305,13 +303,14 @@ const ExcelTable = ({
|
|||
const isFirstCol = cIdx === 0;
|
||||
const isLastCol = cIdx === columns.length - 1;
|
||||
return (
|
||||
<td key={cIdx} className={`border-r border-slate-100 dark:border-[#252525] px-[clamp(0.75rem,1.5vw,1rem)] whitespace-nowrap overflow-hidden text-ellipsis text-slate-700 dark:text-stone-300 ${idx === 0 && isFirstCol ? 'rounded-tl-xl' : ''} ${idx === 0 && isLastCol ? 'rounded-tr-xl' : ''} ${isLastRow && isFirstCol ? 'rounded-bl-xl' : ''} ${isLastRow && isLastCol ? 'rounded-br-xl' : ''}`}>
|
||||
{col.render ? col.render(row) : (
|
||||
<span className={col.className}>{row[col.field] || '-'}</span>
|
||||
)}
|
||||
<td key={cIdx} style={{ maxWidth: col.width || 120 }} className={`border-r border-slate-100 dark:border-[#252525] px-4 text-slate-700 dark:text-stone-300 overflow-hidden ${idx === 0 && isFirstCol ? 'rounded-tl-xl' : ''} ${idx === 0 && isLastCol ? 'rounded-tr-xl' : ''} ${isLastRow && isFirstCol ? 'rounded-bl-xl' : ''} ${isLastRow && isLastCol ? 'rounded-br-xl' : ''}`}>
|
||||
<div className="truncate w-full">
|
||||
{col.render ? col.render(row) : (
|
||||
<span className={`text-[13px] ${col.className}`}>{row[col.field] || '-'}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)})}
|
||||
<td className=""></td>
|
||||
</tr>
|
||||
)})}
|
||||
{/* Empty rows filler if needed */}
|
||||
|
|
@ -333,23 +332,23 @@ const ExcelTable = ({
|
|||
<div className="flex h-full items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-4 h-full relative group min-w-[120px]">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-emerald-500 rounded-r-sm"></div>
|
||||
<span className="text-[clamp(0.875rem,1vw,0.9375rem)] uppercase font-medium text-slate-500 dark:text-slate-400 tracking-wider">Total:</span>
|
||||
<span className="text-[clamp(0.9375rem,1.2vw,1.125rem)] font-semibold text-slate-800 dark:text-stone-200">{processedData.length}</span>
|
||||
<span className="text-[10px] uppercase font-medium text-slate-500 dark:text-slate-400 tracking-wider">Total:</span>
|
||||
<span className="text-xs font-semibold text-slate-800 dark:text-stone-200">{processedData.length}</span>
|
||||
|
||||
<div className="absolute right-0 top-2 bottom-2 w-[1px] bg-slate-200 dark:bg-[#333]"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 h-full relative group min-w-[100px]">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-emerald-500 rounded-r-sm opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="text-[clamp(0.875rem,1vw,0.9375rem)] uppercase font-medium text-slate-500 dark:text-slate-400 tracking-wider">Página:</span>
|
||||
<span className="text-[clamp(0.9375rem,1.2vw,1.125rem)] font-semibold text-slate-800 dark:text-stone-200">{currentPage} / {totalPages || 1}</span>
|
||||
<span className="text-[10px] uppercase font-medium text-slate-500 dark:text-slate-400 tracking-wider">Página:</span>
|
||||
<span className="text-xs font-semibold text-slate-800 dark:text-stone-200">{currentPage} / {totalPages || 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Pagination Controls */}
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<div className="text-[clamp(0.875rem,1.1vw,1rem)] text-slate-500 dark:text-slate-400 mr-3 hidden md:block">
|
||||
Exibindo <span className="font-semibold text-slate-700 dark:text-stone-300">{Math.min((currentPage - 1) * pageSize + 1, processedData.length)} a {Math.min(currentPage * pageSize, processedData.length)}</span>
|
||||
<div className="text-[10px] text-slate-500 dark:text-slate-400 mr-3 hidden md:block">
|
||||
Exibindo <span className="font-semibold text-slate-700 dark:text-stone-300">{Math.min((currentPage - 1) * pageSize + 1, processedData.length)} a {Math.min(currentPage * pageSize, processedData.length)}</span>
|
||||
</div>
|
||||
|
||||
{/* First Page */}
|
||||
|
|
@ -371,9 +370,9 @@ const ExcelTable = ({
|
|||
</button>
|
||||
|
||||
{/* Manual Page Buttons (Simple logic) */}
|
||||
<div className="flex gap-1 mx-1">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-xl bg-emerald-500 text-white dark:text-black font-semibold text-[clamp(1rem,1.2vw,1.125rem)] transition-colors shadow-lg shadow-emerald-500/10">
|
||||
{currentPage}
|
||||
<div className="flex gap-1 mx-1">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-xl bg-emerald-500 text-white dark:text-black font-semibold text-xs transition-colors shadow-lg shadow-emerald-500/10">
|
||||
{currentPage}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,318 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Users,
|
||||
AlertCircle,
|
||||
CalendarCheck,
|
||||
Clock,
|
||||
Building2,
|
||||
AlertTriangle,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
||||
PieChart, Pie, Legend
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* Dashboard de estatísticas para Contratos de Experiência.
|
||||
*
|
||||
* Estrutura da API (/contrato/colaboradores/estatisticas):
|
||||
* {
|
||||
* experiencia: { a_vencer_15_dias_total, a_vencer_15_dias_detalhado, vencidos_total, vencidos_detalhado }
|
||||
* prorrogacao: { a_vencer_15_dias_total, a_vencer_15_dias_detalhado, vencidos_total, vencidos_detalhado }
|
||||
* resumo: { total_contratos, por_empresa: [{empresa, total}], por_status: [{global_status, total}] }
|
||||
* }
|
||||
*/
|
||||
export const ExperienceContractDashboard = ({ stats, fetchStatistics, filters, setFilters }) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics(filters.empresa);
|
||||
}, [filters.empresa, fetchStatistics]);
|
||||
|
||||
// Desestrutura a resposta da API
|
||||
const experiencia = stats?.experiencia || {};
|
||||
const prorrogacao = stats?.prorrogacao || {};
|
||||
const resumo = stats?.resumo || {};
|
||||
|
||||
const metricCards = [
|
||||
{
|
||||
label: 'Total de Contratos',
|
||||
value: resumo.total_contratos ?? 0,
|
||||
icon: Users,
|
||||
color: 'bg-indigo-500/10 text-indigo-500',
|
||||
borderColor: 'border-indigo-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Exp. a Vencer (15 dias)',
|
||||
value: experiencia.a_vencer_15_dias_total ?? 0,
|
||||
icon: Clock,
|
||||
color: 'bg-amber-500/10 text-amber-500',
|
||||
borderColor: 'border-amber-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Exp. Vencidas',
|
||||
value: experiencia.vencidos_total ?? 0,
|
||||
icon: AlertCircle,
|
||||
color: 'bg-rose-500/10 text-rose-500',
|
||||
borderColor: 'border-rose-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Prorrog. a Vencer (15 dias)',
|
||||
value: prorrogacao.a_vencer_15_dias_total ?? 0,
|
||||
icon: CalendarCheck,
|
||||
color: 'bg-orange-500/10 text-orange-500',
|
||||
borderColor: 'border-orange-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Prorrogações Vencidas',
|
||||
value: prorrogacao.vencidos_total ?? 0,
|
||||
icon: TrendingDown,
|
||||
color: 'bg-red-500/10 text-red-500',
|
||||
borderColor: 'border-red-500/20'
|
||||
}
|
||||
];
|
||||
|
||||
const statusColorMap = {
|
||||
'Admissão': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'Experiência': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'Prorrogação': 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'Vencimento de Experiência': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
};
|
||||
|
||||
/** Tooltip reutilizável com classes Tailwind — adapta-se ao dark mode */
|
||||
const ChartTooltip = ({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-white/10 rounded-xl px-4 py-2.5 shadow-xl text-xs">
|
||||
{label && <p className="font-bold text-slate-700 dark:text-slate-200 mb-1">{label}</p>}
|
||||
{payload.map((p, i) => (
|
||||
<p key={i} className="text-slate-500 dark:text-slate-400">
|
||||
Contratos: <span className="font-bold text-slate-800 dark:text-white">{p.value}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700 overflow-y-auto h-full pb-8">
|
||||
|
||||
{/* Filtro de Empresa */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-bold tracking-tight text-slate-800 dark:text-white">Panorama Geral</h2>
|
||||
<p className="text-sm text-slate-500">Métricas de contratos de experiência por empresa</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-white dark:bg-white/2 p-2 rounded-2xl border border-slate-200 dark:border-white/5">
|
||||
<Building2 size={16} className="text-slate-400 ml-2" />
|
||||
<Select
|
||||
value={filters.empresa || 'all'}
|
||||
onValueChange={(val) => setFilters({ empresa: val === 'all' ? '' : val })}
|
||||
>
|
||||
<SelectTrigger className="w-[280px] border-none bg-transparent shadow-none focus:ring-0">
|
||||
<SelectValue placeholder="Todas as Empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-2xl shadow-2xl border-slate-200 dark:border-white/10">
|
||||
<SelectItem value="all">Todas as Empresas</SelectItem>
|
||||
{(resumo.por_empresa || [])
|
||||
.filter(emp => emp?.empresa) // garante que não há value vazio
|
||||
.map(emp => (
|
||||
<SelectItem key={emp.empresa} value={emp.empresa}>{emp.empresa}</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards de Métricas */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{metricCards.map((card, idx) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={idx} className={`bg-white dark:bg-white/2 border border-slate-200 dark:border-white/5 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all group`}>
|
||||
<CardContent className="p-6">
|
||||
<div className={`w-10 h-10 rounded-2xl ${card.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-1 leading-tight">{card.label}</p>
|
||||
<h3 className="text-3xl font-bold text-slate-800 dark:text-white tracking-tighter">
|
||||
{card.value}
|
||||
</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Linha 2: Gráficos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Gráfico de Barras — Distribuição por Status */}
|
||||
<Card className="bg-white dark:bg-white/2 border-slate-200 dark:border-white/5 rounded-3xl shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-400">Distribuição por Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{resumo.por_status?.length ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={resumo.por_status.map(item => ({
|
||||
name: item.global_status,
|
||||
total: item.total
|
||||
}))}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 24, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="rgba(148,163,184,0.1)" />
|
||||
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11, fill: '#94a3b8' }} axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={160}
|
||||
tick={{ fontSize: 10, fontWeight: 700, fill: '#64748b', textTransform: 'uppercase' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<ChartTooltip />}
|
||||
cursor={{ fill: 'rgba(99,102,241,0.06)' }}
|
||||
/>
|
||||
|
||||
<Bar dataKey="total" radius={[0, 8, 8, 0]} maxBarSize={28}>
|
||||
{(resumo.por_status || []).map((item) => {
|
||||
const colors = {
|
||||
'Admissão': '#6366f1',
|
||||
'Experiência': '#f59e0b',
|
||||
'Vencimento de Experiência': '#f97316',
|
||||
'Prorrogação': '#10b981'
|
||||
};
|
||||
return <Cell key={item.global_status} fill={colors[item.global_status] || '#6366f1'} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 text-center py-10">Nenhum dado disponível</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gráfico de Pizza — Contratos por Empresa */}
|
||||
<Card className="bg-white dark:bg-white/2 border-slate-200 dark:border-white/5 rounded-3xl shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-400">Contratos por Empresa</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{resumo.por_empresa?.filter(e => e?.empresa)?.length ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={resumo.por_empresa.filter(e => e?.empresa).map(e => ({ name: e.empresa, value: e.total }))}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
outerRadius={80}
|
||||
innerRadius={44}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{resumo.por_empresa.filter(e => e?.empresa).map((entry, idx) => {
|
||||
const PIE_COLORS = ['#6366f1', '#f59e0b', '#10b981', '#f97316', '#ec4899', '#14b8a6'];
|
||||
return <Cell key={entry.empresa} fill={PIE_COLORS[idx % PIE_COLORS.length]} />;
|
||||
})}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
|
||||
<Legend
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value) => (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: '#64748b' }}>
|
||||
{value.length > 30 ? value.substring(0, 28) + '…' : value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 text-center py-10">Nenhum dado disponível</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Linha 3: Vencimentos Críticos de Experiência */}
|
||||
{(experiencia.vencidos_detalhado?.length > 0 || experiencia.a_vencer_15_dias_detalhado?.length > 0) && (
|
||||
<Card className="bg-white dark:bg-white/2 border-slate-200 dark:border-white/5 rounded-3xl shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-400 flex items-center gap-2">
|
||||
<AlertTriangle size={16} className="text-rose-500" />
|
||||
Alertas de Vencimento — Experiência
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
...(experiencia.vencidos_detalhado || []).map(c => ({ ...c, _tipo: 'vencido' })),
|
||||
...(experiencia.a_vencer_15_dias_detalhado || []).map(c => ({ ...c, _tipo: 'a_vencer' }))
|
||||
].map(c => (
|
||||
<div key={c.idcontrato_rh} className={`flex items-center justify-between p-4 rounded-2xl border ${c._tipo === 'vencido' ? 'bg-rose-50 border-rose-200 dark:bg-rose-900/10 dark:border-rose-500/20' : 'bg-amber-50 border-amber-200 dark:bg-amber-900/10 dark:border-amber-500/20'}`}>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-700 dark:text-white">
|
||||
{c.nome_colaborador || `ID #${c.idcolaborador}`} — {c.empresa}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">Vencimento: {c.vencimento_exp || '—'} | Admissão: {c.data_admissao || '—'}</p>
|
||||
</div>
|
||||
<Badge className={`text-[10px] font-bold uppercase tracking-widest ${c._tipo === 'vencido' ? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'}`}>
|
||||
{c._tipo === 'vencido' ? 'Vencido' : 'A Vencer'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Linha 4: Vencimentos Críticos de Prorrogação */}
|
||||
{(prorrogacao.vencidos_detalhado?.length > 0 || prorrogacao.a_vencer_15_dias_detalhado?.length > 0) && (
|
||||
<Card className="bg-white dark:bg-white/2 border-slate-200 dark:border-white/5 rounded-3xl shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-widest text-slate-400 flex items-center gap-2">
|
||||
<AlertTriangle size={16} className="text-orange-500" />
|
||||
Alertas de Vencimento — Prorrogação
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
...(prorrogacao.vencidos_detalhado || []).map(c => ({ ...c, _tipo: 'vencido' })),
|
||||
...(prorrogacao.a_vencer_15_dias_detalhado || []).map(c => ({ ...c, _tipo: 'a_vencer' }))
|
||||
].map(c => (
|
||||
<div key={c.idcontrato_rh} className={`flex items-center justify-between p-4 rounded-2xl border ${c._tipo === 'vencido' ? 'bg-rose-50 border-rose-200 dark:bg-rose-900/10 dark:border-rose-500/20' : 'bg-amber-50 border-amber-200 dark:bg-amber-900/10 dark:border-amber-500/20'}`}>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-700 dark:text-white">
|
||||
{c.nome_colaborador || `ID #${c.idcolaborador}`} — {c.empresa}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">Prorrogação: {c.prorrogacao_exp || '—'} | Admissão: {c.data_admissao || '—'}</p>
|
||||
</div>
|
||||
<Badge className={`text-[10px] font-bold uppercase tracking-widest ${c._tipo === 'vencido' ? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'}`}>
|
||||
{c._tipo === 'vencido' ? 'Vencido' : 'A Vencer'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
X,
|
||||
Save,
|
||||
ChevronRight,
|
||||
Briefcase,
|
||||
Calendar,
|
||||
Building2,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
/**
|
||||
* Converte data do formato DD/MM/YYYY (API) → YYYY-MM-DD (input type=date)
|
||||
*/
|
||||
const toInputDate = (val) => {
|
||||
if (!val) return '';
|
||||
// Já está no formato ISO
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(val)) return val.substring(0, 10);
|
||||
// Formato BR: DD/MM/YYYY
|
||||
const [d, m, y] = val.split('/');
|
||||
if (d && m && y) return `${y}-${m.padStart(2,'0')}-${d.padStart(2,'0')}`;
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar customizada para formulário de Contrato de Experiência.
|
||||
* Mantendo o padrão estético premium do iT Guys.
|
||||
* Aceita `prefillData` para pré-popular dados ao abrir via painel de colaborador.
|
||||
*/
|
||||
export const ExperienceContractFormSidebar = ({ isOpen, onClose, onSubmit, initialData, prefillData }) => {
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
// Modo de edição: converte datas para formato ISO antes de popular o form
|
||||
const vals = {
|
||||
...initialData,
|
||||
data_admissao: toInputDate(initialData.data_admissao),
|
||||
vencimento_exp: toInputDate(initialData.vencimento_exp),
|
||||
prorrogacao_exp: toInputDate(initialData.prorrogacao_exp),
|
||||
};
|
||||
reset(vals);
|
||||
// setValue explícito nos campos Radix Select para garantir re-render correto
|
||||
setValue('empresa', initialData.empresa || 'B2B TRANSPORTES E LOGISTICAS LTDA');
|
||||
setValue('global_status', initialData.global_status || 'Admissão');
|
||||
} else if (prefillData) {
|
||||
// Modo de criação via painel de colaborador
|
||||
reset({
|
||||
idcolaborador: prefillData.idcolaborador || '',
|
||||
data_admissao: toInputDate(prefillData.data_admissao) || '',
|
||||
empresa: prefillData.empresa || 'B2B TRANSPORTES E LOGISTICAS LTDA',
|
||||
vencimento_exp: '',
|
||||
prorrogacao_exp: '',
|
||||
global_status: 'Admissão'
|
||||
});
|
||||
setValue('empresa', prefillData.empresa || 'B2B TRANSPORTES E LOGISTICAS LTDA');
|
||||
} else {
|
||||
// Modo de criação livre
|
||||
reset({
|
||||
idcolaborador: '',
|
||||
data_admissao: '',
|
||||
empresa: 'B2B TRANSPORTES E LOGISTICAS LTDA',
|
||||
vencimento_exp: '',
|
||||
prorrogacao_exp: '',
|
||||
global_status: 'Admissão'
|
||||
});
|
||||
setValue('empresa', 'B2B TRANSPORTES E LOGISTICAS LTDA');
|
||||
}
|
||||
}, [initialData, prefillData, reset, setValue, isOpen]);
|
||||
|
||||
const handleFormSubmit = (data) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[100]"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 h-full w-full max-w-[480px] bg-white dark:bg-[#0a0a0a] border-l border-slate-200 dark:border-white/5 z-[101] shadow-2xl flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-100 dark:border-white/5 flex items-center justify-between bg-zinc-50/50 dark:bg-white/2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-2xl">
|
||||
<Briefcase className="text-indigo-500" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800 dark:text-white tracking-tight">
|
||||
{initialData ? 'Editar Contrato' : 'Novo Contrato'}
|
||||
</h2>
|
||||
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mt-0.5">Gestão de Experiência</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-white/5 rounded-xl transition-colors text-slate-400"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<form id="experience-contract-form" onSubmit={handleSubmit(handleFormSubmit)} className="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar">
|
||||
|
||||
{/* Colaborador ID — oculto, enviado automaticamente */}
|
||||
<input type="hidden" {...register('idcolaborador')} />
|
||||
{/* {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User size={14} className="text-indigo-500" />
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">ID do Colaborador</Label>
|
||||
</div>
|
||||
<Input
|
||||
{...register('idcolaborador', { required: true })}
|
||||
disabled={!!initialData || !!prefillData}
|
||||
placeholder="Ex: 12345"
|
||||
className="h-12 rounded-2xl bg-zinc-50 dark:bg-white/2 border-slate-200 dark:border-white/5 focus:ring-4 focus:ring-indigo-500/10 transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
} */}
|
||||
|
||||
{/* Empresa */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 size={14} className="text-indigo-500" />
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Empresa Responsável</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={watch('empresa')}
|
||||
onValueChange={(val) => setValue('empresa', val)}
|
||||
>
|
||||
<SelectTrigger className="h-12 rounded-2xl bg-zinc-50 dark:bg-white/2 border-slate-200 dark:border-white/5">
|
||||
<SelectValue placeholder="Selecione a empresa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-2xl border-slate-200 dark:border-white/10 shadow-xl">
|
||||
<SelectItem value="B2B TRANSPORTES E LOGISTICAS LTDA">B2B TRANSPORTES E LOGISTICAS LTDA</SelectItem>
|
||||
<SelectItem value="PETY SERVICE LTDA">PETY SERVICE LTDA</SelectItem>
|
||||
<SelectItem value="RCS SERVICOS PROFISSIONAIS LTDA">RCS SERVICOS PROFISSIONAIS LTDA</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Data Admissão — sempre visível */}
|
||||
<div className={`grid gap-6 ${initialData ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar size={14} className="text-indigo-500" />
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Data Admissão</Label>
|
||||
</div>
|
||||
<Input
|
||||
type="date"
|
||||
{...register('data_admissao', { required: true })}
|
||||
className="h-12 rounded-2xl bg-zinc-50 dark:bg-white/2 border-slate-200 dark:border-white/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Global — apenas na edição */}
|
||||
{/* {initialData && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar size={14} className="text-indigo-500" />
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Status Global</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={watch('global_status')}
|
||||
onValueChange={(val) => setValue('global_status', val)}
|
||||
>
|
||||
<SelectTrigger className="h-12 rounded-2xl bg-zinc-50 dark:bg-white/2 border-slate-200 dark:border-white/5">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-2xl border-slate-200 dark:border-white/10 shadow-xl">
|
||||
<SelectItem value="Admissão">Admissão</SelectItem>
|
||||
<SelectItem value="Vencimento de Experiência">Vencimento Exp.</SelectItem>
|
||||
<SelectItem value="Prorrogação">Prorrogação</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Em criação: garante envio de 'Admissão' como valor do status */}
|
||||
{!initialData && (
|
||||
<input type="hidden" {...register('global_status')} value="Admissão" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vencimentos */}
|
||||
<div className="p-6 bg-zinc-50 dark:bg-white/2 rounded-[24px] border border-dashed border-slate-200 dark:border-white/10 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Vencimento Experiência</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...register('vencimento_exp')}
|
||||
className="h-12 rounded-2xl bg-white dark:bg-[#0a0a0a] border-slate-200 dark:border-white/5 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Prorrogação Experiência</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...register('prorrogacao_exp')}
|
||||
className="h-12 rounded-2xl bg-white dark:bg-[#0a0a0a] border-slate-200 dark:border-white/5 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-8 border-t border-slate-100 dark:border-white/5">
|
||||
<Button
|
||||
type="submit"
|
||||
form="experience-contract-form"
|
||||
className="w-full h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold uppercase tracking-[0.1em] text-xs shadow-xl shadow-indigo-500/20 group transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Save size={18} />
|
||||
{initialData ? 'Salvar Alterações' : 'Criar Contrato'}
|
||||
<ChevronRight size={16} className="ml-1 group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { Calendar, Building2, ChevronRight } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Mapa de cores por status de coluna.
|
||||
*/
|
||||
const COLUMN_STYLES = {
|
||||
'Admissão': { header: 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200', accent: 'border-slate-300 dark:border-slate-600' },
|
||||
'Experiência': { header: 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300', accent: 'border-indigo-300 dark:border-indigo-700' },
|
||||
'Vencimento de Experiência': { header: 'bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-300', accent: 'border-orange-300 dark:border-orange-600' },
|
||||
'Prorrogação': { header: 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300', accent: 'border-emerald-300 dark:border-emerald-600' }
|
||||
};
|
||||
|
||||
const STATUS_BADGE = {
|
||||
'Admissão': 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
|
||||
'Experiência': 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
'Vencimento de Experiência': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
'Prorrogação': 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
};
|
||||
|
||||
/**
|
||||
* Card individual do Kanban de Contratos de Experiência.
|
||||
*/
|
||||
const ContractCard = ({ contract, onCardClick, onDragStart }) => {
|
||||
const style = COLUMN_STYLES[contract.global_status] || COLUMN_STYLES['Admissão'];
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onClick={() => onCardClick?.(contract)}
|
||||
className={`
|
||||
bg-white dark:bg-white/[0.03] rounded-2xl border border-slate-200 dark:border-white/8
|
||||
p-4 cursor-pointer select-none
|
||||
hover:shadow-md hover:border-indigo-300/50 dark:hover:border-indigo-700/40
|
||||
active:scale-[0.98] transition-all duration-200 group
|
||||
`}
|
||||
>
|
||||
{/* Colaborador Avatar & Info */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold text-[11px] flex-shrink-0">
|
||||
{contract.nome_colaborador
|
||||
? contract.nome_colaborador.trim().split(' ').slice(0, 2).map(p => p[0]).join('').toUpperCase()
|
||||
: `#${contract.idcolaborador}`}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-[13px] font-bold text-slate-800 dark:text-white leading-tight truncate" title={contract.nome_colaborador}>
|
||||
{contract.nome_colaborador || 'Sem nome'}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Building2 size={10} className="text-slate-400" />
|
||||
<span className="text-[10px] font-semibold text-slate-400 truncate max-w-[150px]">
|
||||
{contract.empresa || 'Empresa não informada'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datas */}
|
||||
<div className="space-y-2 mb-3">
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold text-slate-400">Venc. Exp.</span>
|
||||
<span className="text-xs font-bold text-orange-600 dark:text-orange-400 font-mono">
|
||||
{contract.vencimento_exp || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold text-slate-400">Prorrogação</span>
|
||||
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400 font-mono">
|
||||
{contract.prorrogacao_exp || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: global_status badge + botão editar */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-white/5">
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-lg ${STATUS_BADGE[contract.global_status] || STATUS_BADGE['Admissão']}`}>
|
||||
{contract.global_status}
|
||||
</span>
|
||||
<ChevronRight size={14} className="text-slate-300 group-hover:text-indigo-500 group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Coluna do Kanban com scroll interno.
|
||||
*/
|
||||
const KanbanColumn = ({ column, onCardClick, onCardMove }) => {
|
||||
const style = COLUMN_STYLES[column.id] || COLUMN_STYLES['Admissão'];
|
||||
|
||||
const handleDragOver = (e) => e.preventDefault();
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
const data = e.dataTransfer.getData('application/json');
|
||||
if (!data) return;
|
||||
const card = JSON.parse(data);
|
||||
if (card.global_status !== column.id) {
|
||||
// Prioriza idcolaborador para a rota de edição de status global
|
||||
onCardMove?.(card.idcolaborador || card.idcontrato_rh, column.id, card);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col min-w-[280px] max-w-[320px] flex-1"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Cabeçalho da coluna */}
|
||||
<div className={`flex items-center justify-between px-4 py-3 rounded-2xl mb-3 ${style.header} border ${style.accent}`}>
|
||||
<span className="text-xs font-black uppercase tracking-widest">{column.title}</span>
|
||||
<span className="min-w-[24px] h-6 flex items-center justify-center bg-white/60 dark:bg-black/20 rounded-lg text-[10px] font-bold px-2">
|
||||
{column.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards com scroll */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[calc(100vh-320px)] pr-1 custom-scrollbar space-y-3">
|
||||
{column.cards.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-slate-300 dark:text-slate-600">
|
||||
<Calendar size={28} className="mb-2 opacity-40" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest opacity-60">Nenhum contrato</p>
|
||||
</div>
|
||||
) : (
|
||||
column.cards.map(card => (
|
||||
<ContractCard
|
||||
key={card.idcontrato_rh || card.idcolaborador}
|
||||
contract={card}
|
||||
onCardClick={onCardClick}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(card));
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Kanban customizado para Contratos de Experiência do RH.
|
||||
* Layout de card com: empresa, data_admissao, vencimento_exp, prorrogacao_exp, global_status.
|
||||
*/
|
||||
export const ExperienceContractKanban = ({ contracts, onCardMove, onCardClick, loading }) => {
|
||||
|
||||
const COLUMNS_DEF = [
|
||||
{ id: 'Experiência', title: 'Experiência' },
|
||||
{ id: 'Vencimento de Experiência', title: 'Vencimento de Experiência' },
|
||||
{ id: 'Prorrogação', title: 'Prorrogação' }
|
||||
];
|
||||
|
||||
const columns = useMemo(() =>
|
||||
COLUMNS_DEF.map(col => ({
|
||||
...col,
|
||||
cards: contracts.filter(c =>
|
||||
col.id === 'Admissão'
|
||||
? (c.global_status === 'Admissão' || !c.global_status)
|
||||
: c.global_status === col.id
|
||||
)
|
||||
}))
|
||||
, [contracts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-indigo-500/20 border-t-indigo-500 rounded-full animate-spin" />
|
||||
<p className="text-slate-500 font-medium animate-pulse uppercase tracking-widest text-[10px]">Carregando Kanban...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-full overflow-x-auto pb-4 custom-scrollbar">
|
||||
{columns.map(col => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
onCardClick={onCardClick}
|
||||
onCardMove={onCardMove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import React from 'react';
|
||||
import ExcelTable from './ExcelTable';
|
||||
import { Building2, CalendarDays } from 'lucide-react';
|
||||
|
||||
// ─── Helpers de Data ─────────────────────────────────────────────────────────
|
||||
|
||||
const safeParseDate = (str) => {
|
||||
if (!str) return null;
|
||||
const m = str.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||
if (m) return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||||
const d = new Date(str);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
const formatDate = (str) => {
|
||||
if (!str) return '-';
|
||||
if (/^\d{2}\/\d{2}\/\d{4}$/.test(str)) return str;
|
||||
const d = safeParseDate(str);
|
||||
return d ? d.toLocaleDateString('pt-BR') : '-';
|
||||
};
|
||||
|
||||
// ─── Badge de Status ──────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_STYLES = {
|
||||
'Admissão': {
|
||||
cls: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-500/10 dark:text-slate-300 dark:border-slate-500/20',
|
||||
dot: 'bg-slate-400',
|
||||
},
|
||||
'Experiência': {
|
||||
cls: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
'Prorrogação': {
|
||||
cls: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20',
|
||||
dot: 'bg-emerald-500',
|
||||
},
|
||||
'Vencimento de Experiência': {
|
||||
cls: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/10 dark:text-orange-400 dark:border-orange-500/20',
|
||||
dot: 'bg-orange-500',
|
||||
},
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const style = STATUS_STYLES[status] || {
|
||||
cls: 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700',
|
||||
dot: 'bg-slate-400',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold uppercase border shadow-sm tracking-wide whitespace-nowrap ${style.cls}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${style.dot}`} />
|
||||
{status || 'Não Definido'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Avatar com iniciais ──────────────────────────────────────────────────────
|
||||
|
||||
const ColaboradorAvatar = ({ nome, id }) => {
|
||||
const initials = nome
|
||||
? nome.trim().split(' ').slice(0, 2).map(p => p[0]).join('').toUpperCase()
|
||||
: `#${id}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-500/10 flex-shrink-0 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold text-[11px] select-none">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span
|
||||
className="font-semibold text-slate-800 dark:text-slate-200 leading-tight truncate block text-[13px]"
|
||||
title={nome || `ID ${id}`}
|
||||
>
|
||||
{nome || <span className="text-slate-400 italic font-normal text-xs">Sem nome</span>}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-medium">ID {id}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Coluna de Data ───────────────────────────────────────────────────────────
|
||||
|
||||
const DateCell = ({ value }) => (
|
||||
<div className="flex items-center gap-1.5 text-[13px] font-mono text-slate-600 dark:text-slate-300">
|
||||
<CalendarDays size={13} className="text-slate-400 flex-shrink-0" />
|
||||
{formatDate(value)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Componente Principal ─────────────────────────────────────────────────────
|
||||
|
||||
export const ExperienceContractTable = ({ contracts, loading, onEdit }) => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
field: 'idcolaborador',
|
||||
header: 'ID',
|
||||
width: 65,
|
||||
className: 'font-mono text-[13px] text-slate-500',
|
||||
},
|
||||
{
|
||||
field: 'nome_colaborador',
|
||||
header: 'Colaborador',
|
||||
width: 270,
|
||||
render: (row) => <ColaboradorAvatar nome={row.nome_colaborador} id={row.idcolaborador} />,
|
||||
},
|
||||
{
|
||||
field: 'empresa',
|
||||
header: 'Empresa',
|
||||
width: 260,
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Building2 size={13} className="text-slate-400 flex-shrink-0" />
|
||||
<span className="text-[13px] text-slate-600 dark:text-slate-300 font-medium truncate" title={row.empresa}>
|
||||
{row.empresa || '-'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'vencimento_exp',
|
||||
header: 'Vencimento Exp.',
|
||||
width: 148,
|
||||
render: (row) => <DateCell value={row.vencimento_exp} />,
|
||||
},
|
||||
{
|
||||
field: 'prorrogacao_exp',
|
||||
header: 'Prorrogação Exp.',
|
||||
width: 155,
|
||||
render: (row) => <DateCell value={row.prorrogacao_exp} />,
|
||||
},
|
||||
{
|
||||
field: 'global_status',
|
||||
header: 'Status',
|
||||
render: (row) => <StatusBadge status={row.global_status} />,
|
||||
},
|
||||
];
|
||||
|
||||
const filterDefs = [
|
||||
{ field: 'empresa', type: 'select', label: 'Empresa' },
|
||||
{ field: 'global_status', type: 'select', label: 'Status' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full bg-white dark:bg-[#1b1b1b] rounded-2xl border border-slate-200 dark:border-white/5 overflow-hidden flex flex-col shadow-xl shadow-slate-200/50 dark:shadow-none"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<ExcelTable
|
||||
data={contracts}
|
||||
columns={columns}
|
||||
filterDefs={filterDefs}
|
||||
loading={loading}
|
||||
pageSize={10}
|
||||
onEdit={onEdit}
|
||||
onRowClick={onEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -195,6 +195,9 @@
|
|||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collapsed .rhs-label,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import {
|
|||
FileCheck,
|
||||
Gift,
|
||||
ClipboardList,
|
||||
UserCog
|
||||
UserCog,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
|
|
@ -50,7 +51,8 @@ const MENU_ITEMS = [
|
|||
children: [
|
||||
// { id: 'faltas-atestados', label: 'Faltas e Atestados', path: '/plataforma/rh/faltas-atestados', icon: ClipboardList },
|
||||
{ id: 'aniversariantes', label: 'Aniversariantes', path: '/plataforma/rh/aniversariantes', icon: Gift },
|
||||
// { id: 'contratos-experiencia', label: 'Contratos de Experiência', path: '/plataforma/rh/contratos-experiencia', icon: FileCheck },
|
||||
{ id: 'contratos-experiencia', label: 'Contratos de Experiência', path: '/plataforma/rh/contratos-experiencia', icon: FileCheck },
|
||||
{ id: 'gestao-ferias', label: 'Gestão de Férias', path: '/plataforma/rh/gestao-ferias', icon: Calendar },
|
||||
// { id: 'ponto', label: 'Ponto Eletrônico', path: '/plataforma/rh/ponto', icon: UserCheck },
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { rhExperienceContractService } from '../services/rhExperienceContractService';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Hook para gerenciar contratos de experiência do RH.
|
||||
*/
|
||||
export const useExperienceContracts = () => {
|
||||
const [contracts, setContracts] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('registros'); // 'registros', 'dashboards'
|
||||
const [displayMode, setDisplayMode] = useState('kanban'); // 'kanban', 'tabela'
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
empresa: ''
|
||||
});
|
||||
|
||||
const fetchContracts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await rhExperienceContractService.getContracts();
|
||||
const data = response?.dados || response?.Base_Dados_API || response || [];
|
||||
setContracts(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar contratos:', error);
|
||||
toast.error('Erro ao carregar contratos de experiência.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStatistics = useCallback(async (empresa) => {
|
||||
try {
|
||||
const response = await rhExperienceContractService.getStatistics(empresa ? { empresa } : {});
|
||||
// Preserva a estrutura completa: { experiencia, prorrogacao, resumo }
|
||||
const data = response?.dados || response?.Base_Dados_API || response;
|
||||
setStats(data || null);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar estatísticas:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreateContract = async (payload) => {
|
||||
try {
|
||||
await rhExperienceContractService.createContract(payload);
|
||||
toast.success('Contrato criado com sucesso!');
|
||||
fetchContracts();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar contrato:', error);
|
||||
toast.error('Erro ao criar contrato.');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateContract = async (payload) => {
|
||||
try {
|
||||
await rhExperienceContractService.updateContract(payload);
|
||||
toast.success('Contrato atualizado com sucesso!');
|
||||
fetchContracts();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar contrato:', error);
|
||||
toast.error('Erro ao atualizar contrato.');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCard = async (idcolaborador, newStatus) => {
|
||||
try {
|
||||
await rhExperienceContractService.updateGlobalStatus({
|
||||
idcolaborador,
|
||||
global_status: newStatus
|
||||
});
|
||||
// Update local state for immediate feedback if needed,
|
||||
// or just refetch. Refetching is safer for data consistency.
|
||||
fetchContracts();
|
||||
} catch (error) {
|
||||
console.error('Erro ao mover contrato:', error);
|
||||
toast.error('Erro ao atualizar status do contrato.');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredContracts = useMemo(() => {
|
||||
return contracts.filter(c => {
|
||||
const matchesSearch = (c.nome || c.nome_completo || '').toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
(c.idcolaborador?.toString() || '').includes(filters.search);
|
||||
const matchesEmpresa = !filters.empresa || c.empresa === filters.empresa;
|
||||
return matchesSearch && matchesEmpresa;
|
||||
});
|
||||
}, [contracts, filters]);
|
||||
|
||||
return {
|
||||
contracts: filteredContracts,
|
||||
rawContracts: contracts,
|
||||
stats,
|
||||
loading,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
displayMode,
|
||||
setDisplayMode,
|
||||
filters,
|
||||
setFilters: (newFilters) => setFilters(prev => ({ ...prev, ...newFilters })),
|
||||
fetchContracts,
|
||||
fetchStatistics,
|
||||
handleCreateContract,
|
||||
handleUpdateContract,
|
||||
handleMoveCard
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { create } from 'zustand';
|
||||
import { rhService } from '../services/rhService';
|
||||
import { useRhFeedbackStore } from '../components/RhFeedbackNotification';
|
||||
|
||||
const notify = (type, title, message) => useRhFeedbackStore.getState().notify(type, title, message);
|
||||
|
||||
/**
|
||||
* Hook para Gestão de Férias.
|
||||
* Implementa a lógica de redução de dias por faltas e filtros de período.
|
||||
*/
|
||||
export const useGestaoFerias = create((set, get) => ({
|
||||
vacations: [],
|
||||
loading: false,
|
||||
filters: {
|
||||
month: new Date().getMonth() + 1,
|
||||
year: new Date().getFullYear(),
|
||||
department: ''
|
||||
},
|
||||
|
||||
setFilters: (newFilters) => set((state) => ({
|
||||
filters: { ...state.filters, ...newFilters }
|
||||
})),
|
||||
|
||||
/**
|
||||
* Calcula os dias de direito baseado nas faltas
|
||||
*/
|
||||
calculateEntitledDays: (faltas) => {
|
||||
if (faltas <= 5) return 30;
|
||||
if (faltas <= 14) return 24;
|
||||
if (faltas <= 23) return 18;
|
||||
if (faltas <= 32) return 12;
|
||||
return 0; // Acima de 32 perde o direito
|
||||
},
|
||||
|
||||
fetchVacations: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const { filters } = get();
|
||||
const response = await rhService.getCollaborators(filters);
|
||||
const rawList = Array.isArray(response) ? response : (response?.data || []);
|
||||
|
||||
const mappedVacations = rawList.map(emp => {
|
||||
const admissionDate = emp.data_admissao ? new Date(emp.data_admissao) : new Date(2023, 0, 1);
|
||||
const lastReturn = emp.data_retorno_ferias ? new Date(emp.data_retorno_ferias) : new Date(admissionDate.getTime() + 365*24*60*60*1000);
|
||||
const limitDate = new Date(lastReturn.getTime() + 365*24*60*60*1000);
|
||||
|
||||
const faltas = emp.faltas_periodo || 0;
|
||||
const diasGozados = emp.dias_gozados || 0;
|
||||
const diasDireito = get().calculateEntitledDays(faltas);
|
||||
const diasAGozar = Math.max(0, diasDireito - diasGozados);
|
||||
|
||||
return {
|
||||
id: emp.idcolaborador || emp.id,
|
||||
name: emp.colaboradores || emp.nome,
|
||||
avatar: emp.avatar || null,
|
||||
role: emp.cargo || 'Colaborador',
|
||||
cnpj: emp.cnpj || '00.000.000/0001-00',
|
||||
companyName: (emp.empresas || emp.empresa || 'Empresa Padrão').trim(),
|
||||
admissionDate: admissionDate.toLocaleDateString(),
|
||||
lastReturnDate: lastReturn.toLocaleDateString(),
|
||||
limitDate: limitDate.toLocaleDateString(),
|
||||
limitDateObj: limitDate,
|
||||
faltas,
|
||||
diasGozados,
|
||||
diasDireito,
|
||||
diasAGozar,
|
||||
department: emp.cargo || 'Geral',
|
||||
status: (emp.status_contrato || emp.status || 'Ativo').toUpperCase()
|
||||
};
|
||||
});
|
||||
|
||||
// Calcular Estatísticas
|
||||
const stats = {
|
||||
byCompany: {},
|
||||
monthly: Array(12).fill(0).map((_, i) => ({ month: i + 1, count: 0, urgent: 0 })),
|
||||
vencendo: 0,
|
||||
emGozo: 0,
|
||||
totalDias: 0
|
||||
};
|
||||
|
||||
mappedVacations.forEach(v => {
|
||||
// Por Empresa
|
||||
const company = v.companyName;
|
||||
stats.byCompany[company] = (stats.byCompany[company] || 0) + 1;
|
||||
|
||||
// Por Mês (baseado na data limite de gozo)
|
||||
const month = v.limitDateObj.getMonth();
|
||||
stats.monthly[month].count++;
|
||||
|
||||
const now = new Date();
|
||||
const diffDays = (v.limitDateObj - now) / (1000 * 60 * 60 * 24);
|
||||
if (diffDays < 60) {
|
||||
stats.monthly[month].urgent++;
|
||||
stats.vencendo++;
|
||||
}
|
||||
|
||||
if (v.status === 'FÉRIAS' || v.status === 'FERIAS') stats.emGozo++;
|
||||
stats.totalDias += v.diasAGozar;
|
||||
});
|
||||
|
||||
set({
|
||||
vacations: mappedVacations,
|
||||
stats: {
|
||||
...stats,
|
||||
chartByCompany: Object.entries(stats.byCompany)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value),
|
||||
chartMonthly: stats.monthly.map(m => ({
|
||||
name: ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'][m.month - 1],
|
||||
Vencendo: m.urgent,
|
||||
Total: m.count,
|
||||
monthIdx: m.month
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
notify('error', 'Erro', 'Falha ao carregar dados de férias: ' + err.message);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
|
@ -29,6 +29,7 @@ const TrabalhadoresView = lazy(() => import('./views/TrabalhadoresView').then(m
|
|||
const ContratosExperienciaView = lazy(() => import('./views/ContratosExperienciaView').then(m => ({ default: m.ContratosExperienciaView })));
|
||||
const GestaoAniversariantesView = lazy(() => import('./views/GestaoAniversariantesView').then(m => ({ default: m.GestaoAniversariantesView })));
|
||||
const GestaoFaltasAtestadosView = lazy(() => import('./views/GestaoFaltasAtestadosView').then(m => ({ default: m.GestaoFaltasAtestadosView })));
|
||||
const GestaoFeriasView = lazy(() => import('./views/GestaoFeriasView').then(m => ({ default: m.GestaoFeriasView })));
|
||||
|
||||
/**
|
||||
* Rotas do módulo de RH.
|
||||
|
|
@ -54,6 +55,7 @@ export const RhRoutes = () => {
|
|||
<Route path="contratos-experiencia" element={<ContratosExperienciaView />} />
|
||||
<Route path="aniversariantes" element={<GestaoAniversariantesView />} />
|
||||
<Route path="faltas-atestados" element={<GestaoFaltasAtestadosView />} />
|
||||
<Route path="gestao-ferias" element={<GestaoFeriasView />} />
|
||||
|
||||
{/* Redirecionamentos de conveniência */}
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import api from '@/services/api';
|
||||
import { handleRequest } from '@/services/serviceUtils';
|
||||
|
||||
/**
|
||||
* Serviço especializado para gestão de contratos de experiência de colaboradores.
|
||||
*/
|
||||
const ENDPOINTS = {
|
||||
CONTRATOS: '/contrato/colaboradores',
|
||||
ESTATISTICAS: '/contrato/colaboradores/estatisticas'
|
||||
};
|
||||
|
||||
export const rhExperienceContractService = {
|
||||
/**
|
||||
* Listagem de contratos de experiência
|
||||
*/
|
||||
getContracts: (params = {}) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.CONTRATOS}/apresentar`, { params });
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Criar novo contrato de experiência
|
||||
*/
|
||||
createContract: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.CONTRATOS, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Editar contrato de experiência
|
||||
*/
|
||||
updateContract: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.CONTRATOS}/edit`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Atualizar status global de um contrato (Kanban move)
|
||||
*/
|
||||
updateGlobalStatus: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.CONTRATOS}/edit/status_global`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Buscar estatísticas para o dashboard de contratos
|
||||
*/
|
||||
getStatistics: (params = {}) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(ENDPOINTS.ESTATISTICAS, { params });
|
||||
return data;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
|
@ -1,235 +1,211 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useContratosExperiencia } from '../hooks/useContratosExperiencia';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
FileCheck,
|
||||
Search,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
CheckCircle
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
LayoutGrid,
|
||||
Table,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Briefcase
|
||||
} from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useExperienceContracts } from '../hooks/useExperienceContracts';
|
||||
import { ExperienceContractKanban } from '../components/ExperienceContractKanban';
|
||||
import { ExperienceContractTable } from '../components/ExperienceContractTable';
|
||||
import { ExperienceContractDashboard } from '../components/ExperienceContractDashboard';
|
||||
import { ExperienceContractFormSidebar } from '../components/ExperienceContractFormSidebar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from '@/components/ui/tabs';
|
||||
|
||||
/**
|
||||
* View de Controle de Vencimentos de Contratos de Experiência
|
||||
* View Principal de Gestão de Contratos de Experiência do RH.
|
||||
* Integra Kanban, Tabela e Dashboard em uma interface unificada.
|
||||
*/
|
||||
export const ContratosExperienciaView = () => {
|
||||
const {
|
||||
vencimentos,
|
||||
loading,
|
||||
filters,
|
||||
fetchVencimentosContratosExperiencia,
|
||||
setFilters
|
||||
} = useContratosExperiencia();
|
||||
const {
|
||||
contracts,
|
||||
stats,
|
||||
loading,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
displayMode,
|
||||
setDisplayMode,
|
||||
filters,
|
||||
setFilters,
|
||||
fetchContracts,
|
||||
fetchStatistics,
|
||||
handleCreateContract,
|
||||
handleUpdateContract,
|
||||
handleMoveCard
|
||||
} = useExperienceContracts();
|
||||
|
||||
useEffect(() => {
|
||||
fetchVencimentosContratosExperiencia();
|
||||
}, [fetchVencimentosContratosExperiencia]);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [selectedContract, setSelectedContract] = useState(null);
|
||||
|
||||
const columns = [
|
||||
{ field: 'id', header: 'ID', width: 80 },
|
||||
{ field: 'nome', header: 'Nome', width: 250 },
|
||||
{ field: 'cargo', header: 'Cargo', width: 180 },
|
||||
{ field: 'base', header: 'Base', width: 120 },
|
||||
{ field: 'empresa', header: 'Empresa', width: 150 },
|
||||
{ field: 'data_admissao', header: 'Admissão', width: 120, render: (row) => (
|
||||
<span className="text-[clamp(1rem,1.2vw,1.125rem)] font-mono">
|
||||
{row.data_admissao ? new Date(row.data_admissao).toLocaleDateString('pt-BR') : '-'}
|
||||
</span>
|
||||
)},
|
||||
{ field: 'data_vencimento', header: 'Vencimento', width: 130, render: (row) => (
|
||||
<span className="text-[clamp(1rem,1.2vw,1.125rem)] font-mono font-semibold">
|
||||
{row.data_vencimento ? new Date(row.data_vencimento).toLocaleDateString('pt-BR') : '-'}
|
||||
</span>
|
||||
)},
|
||||
{ field: 'dias_restantes', header: 'Dias Restantes', width: 140, render: (row) => {
|
||||
if (row.dias_restantes === null) return <span className="text-slate-400">-</span>;
|
||||
const isVencido = row.dias_restantes < 0;
|
||||
const isProximo = row.dias_restantes <= 7;
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-xl text-[clamp(1rem,1.2vw,1.125rem)] font-medium ${
|
||||
isVencido
|
||||
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300'
|
||||
: isProximo
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}`}>
|
||||
{isVencido ? `Vencido há ${Math.abs(row.dias_restantes)} dias` : `${row.dias_restantes} dias`}
|
||||
</span>
|
||||
);
|
||||
}},
|
||||
{ field: 'status_vencimento', header: 'Status', width: 150, render: (row) => {
|
||||
const statusColors = {
|
||||
'Vencido': 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300',
|
||||
'Vence em breve': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'Atenção': 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
'Vigente': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-xl text-[clamp(1rem,1.2vw,1.125rem)] font-medium ${statusColors[row.status_vencimento] || 'bg-slate-100 text-slate-700'}`}>
|
||||
{row.status_vencimento}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
];
|
||||
useEffect(() => {
|
||||
fetchContracts();
|
||||
}, [fetchContracts]);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total de Contratos',
|
||||
value: vencimentos.length,
|
||||
icon: FileCheck,
|
||||
color: 'bg-indigo-500/10 text-indigo-500'
|
||||
},
|
||||
{
|
||||
label: 'Vencidos',
|
||||
value: vencimentos.filter(v => v.dias_restantes !== null && v.dias_restantes < 0).length,
|
||||
icon: AlertCircle,
|
||||
color: 'bg-rose-500/10 text-rose-500'
|
||||
},
|
||||
{
|
||||
label: 'Vencem em 7 dias',
|
||||
value: vencimentos.filter(v => v.dias_restantes !== null && v.dias_restantes >= 0 && v.dias_restantes <= 7).length,
|
||||
icon: Clock,
|
||||
color: 'bg-amber-500/10 text-amber-500'
|
||||
}
|
||||
];
|
||||
const handleEdit = (item) => {
|
||||
setSelectedContract(item);
|
||||
setIsSidebarOpen(true);
|
||||
};
|
||||
|
||||
const filteredVencimentos = useMemo(() => {
|
||||
let result = [...vencimentos];
|
||||
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
result = result.filter(v =>
|
||||
(v.nome || '').toLowerCase().includes(searchLower) ||
|
||||
(v.cargo || '').toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
result = result.filter(v => v.status_vencimento === filters.status);
|
||||
}
|
||||
|
||||
if (filters.dias_vencimento) {
|
||||
const dias = parseInt(filters.dias_vencimento);
|
||||
if (!isNaN(dias)) {
|
||||
result = result.filter(v =>
|
||||
v.dias_restantes !== null &&
|
||||
v.dias_restantes >= 0 &&
|
||||
v.dias_restantes <= dias
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [vencimentos, filters]);
|
||||
const handleNew = () => {
|
||||
setSelectedContract(null);
|
||||
setIsSidebarOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="theme-rh flex-1 flex flex-col h-full w-full overflow-hidden bg-slate-50/50 dark:bg-[#0a0a0a] p-4 sm:p-6 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 sm:p-3 bg-indigo-500/10 rounded-xl">
|
||||
<FileCheck className="text-indigo-500" size={24} />
|
||||
</div>
|
||||
<h1 className="text-[clamp(1.5rem,3vw,2.25rem)] font-semibold text-slate-800 dark:text-white tracking-tight">
|
||||
Vencimentos de Contratos de Experiência
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-500 text-[clamp(1rem,1.4vw,1.25rem)] font-normal ml-1">
|
||||
Controle de vencimentos e renovações de contratos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => fetchVencimentosContratosExperiencia()}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</div>
|
||||
const onFormSubmit = async (data) => {
|
||||
let success = false;
|
||||
if (selectedContract) {
|
||||
// Edit
|
||||
success = await handleUpdateContract({
|
||||
idcontrato_rh: selectedContract.idcontrato_rh,
|
||||
...data
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
success = await handleCreateContract(data);
|
||||
}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
{stats.map((stat, idx) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={idx} className="rounded-2xl border-slate-200 dark:border-white/5 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[clamp(1.5rem,2.8vw,2.25rem)] font-semibold text-slate-800 dark:text-white">
|
||||
{stat.value}
|
||||
if (success) {
|
||||
setIsSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="theme-rh flex-1 flex flex-col h-full w-full overflow-hidden bg-slate-50/50 dark:bg-[#0a0a0a] p-4 sm:p-6 lg:p-10 space-y-8">
|
||||
|
||||
{/* Header / Top Bar */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-8">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-2xl shadow-inner border border-indigo-500/5">
|
||||
<Briefcase className="text-indigo-500" size={24} />
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-slate-800 dark:text-white tracking-tighter leading-none">
|
||||
Contratos de <span className="text-indigo-500">Experiência</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-400 dark:text-slate-500 text-sm md:text-base font-medium italic opacity-80 pl-1">
|
||||
Gerencie registros e acompanhe métricas de admissão
|
||||
</p>
|
||||
<p className="text-[clamp(1rem,1.1vw,1.0625rem)] text-slate-500 font-medium uppercase tracking-wider mt-1">
|
||||
{stat.label}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${stat.color}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<Card className="mb-6 rounded-2xl border-slate-200 dark:border-white/5 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<Input
|
||||
placeholder="Buscar por nome ou cargo..."
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => setFilters({ search: e.target.value })}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* View Selector (Registros vs Dashboards) */}
|
||||
<Tabs value={viewMode} onValueChange={setViewMode} className="bg-white dark:bg-white/2 p-1.5 rounded-[24px] border border-slate-200 dark:border-white/5 shadow-sm">
|
||||
<TabsList className="bg-transparent border-none gap-2">
|
||||
<TabsTrigger
|
||||
value="registros"
|
||||
className="px-6 py-2.5 rounded-[20px] text-[10px] font-bold uppercase tracking-wider data-[state=active]:bg-indigo-500 data-[state=active]:text-white transition-all shadow-none"
|
||||
>
|
||||
<LayoutGrid size={14} className="mr-2" /> Registros
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="dashboards"
|
||||
className="px-6 py-2.5 rounded-[20px] text-[10px] font-bold uppercase tracking-wider data-[state=active]:bg-indigo-500 data-[state=active]:text-white transition-all shadow-none"
|
||||
>
|
||||
<LayoutDashboard size={14} className="mr-2" /> Dashboards
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Button
|
||||
onClick={fetchContracts}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="h-12 w-12 rounded-2xl border-slate-200 dark:border-white/5 bg-white dark:bg-white/2 hover:scale-105 transition-transform"
|
||||
>
|
||||
<RefreshCw size={18} className={`${loading ? 'animate-spin' : ''} text-slate-500`} />
|
||||
</Button>
|
||||
|
||||
{/* Add Button */}
|
||||
{/* <Button
|
||||
onClick={handleNew}
|
||||
className="h-12 px-8 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold uppercase tracking-[0.1em] text-[10px] shadow-xl shadow-indigo-500/20 active:scale-95 transition-all"
|
||||
>
|
||||
<Plus size={18} className="mr-2" /> Novo Contrato
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={filters.status || undefined}
|
||||
onValueChange={(value) => setFilters({ status: value || '' })}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[200px] rounded-xl">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Vencido">Vencido</SelectItem>
|
||||
<SelectItem value="Vence em breve">Vence em breve</SelectItem>
|
||||
<SelectItem value="Atenção">Atenção</SelectItem>
|
||||
<SelectItem value="Vigente">Vigente</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabela */}
|
||||
<Card className="flex-1 flex flex-col overflow-hidden rounded-2xl border-slate-200 dark:border-white/5">
|
||||
<CardHeader className="border-b border-slate-200 dark:border-white/5">
|
||||
<CardTitle className="text-[clamp(1.125rem,2vw,1.5rem)] font-semibold">
|
||||
Contratos ({filteredVencimentos.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<ExcelTable
|
||||
data={filteredVencimentos}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pageSize={50}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
{/* Filter Bar / Mode Switcher (Unicamente para visualização de Registros) */}
|
||||
{viewMode === 'registros' && (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6 animate-in slide-in-from-top-4 duration-500">
|
||||
<div className="relative group max-w-md w-full">
|
||||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" />
|
||||
<Input
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ search: e.target.value })}
|
||||
placeholder="Buscar colaborador por nome ou ID..."
|
||||
className="pl-12 h-14 bg-white dark:bg-white/2 border-slate-200 dark:border-white/5 rounded-[24px] text-xs focus:outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all font-semibold shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 p-1.5 bg-slate-100 dark:bg-white/2 rounded-[24px] border border-slate-200/50 dark:border-white/5 shadow-inner">
|
||||
<button
|
||||
onClick={() => setDisplayMode('kanban')}
|
||||
className={`p-3 rounded-[20px] transition-all ${displayMode === 'kanban' ? 'bg-white dark:bg-slate-700 shadow-lg text-indigo-500 dark:text-white' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'}`}
|
||||
title="Visualização Kanban"
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDisplayMode('tabela')}
|
||||
className={`p-3 rounded-[20px] transition-all ${displayMode === 'tabela' ? 'bg-white dark:bg-slate-700 shadow-lg text-indigo-500 dark:text-white' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'}`}
|
||||
title="Visualização em Tabela"
|
||||
>
|
||||
<Table size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{viewMode === 'registros' ? (
|
||||
displayMode === 'kanban' ? (
|
||||
<div className="flex-1 p-2 overflow-hidden">
|
||||
<ExperienceContractKanban
|
||||
contracts={contracts}
|
||||
onCardMove={handleMoveCard}
|
||||
onCardClick={handleEdit}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
|
||||
<ExperienceContractTable
|
||||
contracts={contracts}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<ExperienceContractDashboard
|
||||
stats={stats}
|
||||
fetchStatistics={fetchStatistics}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar de Formulário */}
|
||||
<ExperienceContractFormSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
onSubmit={onFormSubmit}
|
||||
initialData={selectedContract}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,576 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import {
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
PieChart as PieIcon,
|
||||
BarChart3,
|
||||
ArrowUpRight,
|
||||
Users,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend
|
||||
} from 'recharts';
|
||||
import { useGestaoFerias } from '../hooks/useGestaoFerias';
|
||||
import { RhFeedbackContainer } from '../components/RhFeedbackNotification';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316'];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
const KPISection = React.memo(({ stats, vacations }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ label: 'Férias Críticas', value: stats?.vencendo || 0, icon: AlertTriangle, color: 'text-rose-500', bg: 'bg-rose-500/10' },
|
||||
{ label: 'Colaboradores em Gozo', value: stats?.emGozo || 0, icon: Users, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
{ label: 'Direitos Ativos', value: vacations.length, icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||
{ label: 'Dias Acumulados', value: stats?.totalDias || 0, icon: Clock, color: 'text-amber-500', bg: 'bg-amber-500/10' }
|
||||
].map((card, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5, scale: 1.02 }}
|
||||
className="bg-white dark:bg-[#1b1b1b] p-6 rounded-[28px] border border-slate-200 dark:border-white/5 flex items-center justify-between shadow-sm border-b-4 border-b-slate-100 dark:border-b-white/5 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className={`p-4 rounded-[22px] ${card.bg}`}>
|
||||
<card.icon className={card.color} size={28} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 tracking-[0.1em]">{card.label}</span>
|
||||
<h4 className="text-3xl font-black text-slate-800 dark:text-white leading-none">{card.value}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-xl bg-slate-50 dark:bg-white/5">
|
||||
<ArrowUpRight size={14} className="text-slate-300" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ChartsSection = React.memo(({ stats }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Linear Chart */}
|
||||
<motion.div variants={itemVariants} className="bg-white dark:bg-[#1b1b1b] p-8 rounded-[32px] border border-slate-200 dark:border-white/5 shadow-xl shadow-slate-200/20 dark:shadow-none">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/10 rounded-xl"><BarChart3 size={18} className="text-blue-500" /></div>
|
||||
<h3 className="font-bold text-slate-800 dark:text-white uppercase tracking-widest text-[10px]">Cronograma de Vencimento</h3>
|
||||
</div>
|
||||
<Badge className="bg-blue-500/5 text-blue-500 border-none font-bold text-[9px]">ANUAL</Badge>
|
||||
</div>
|
||||
<div className="h-[280px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats?.chartMonthly || []} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#88888815" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 9, fontWeight: 700, fill: '#94a3b8' }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 9, fontWeight: 700, fill: '#94a3b8' }} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: '#131313', border: 'none', borderRadius: '16px', color: '#fff', fontSize: '10px', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||
cursor={{ fill: '#88888808' }}
|
||||
/>
|
||||
<Bar dataKey="Total" fill="#3b82f6" radius={[6, 6, 0, 0]} barSize={24} />
|
||||
<Bar dataKey="Vencendo" fill="#f43f5e" radius={[6, 6, 0, 0]} barSize={24} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-blue-500"></div><span className="text-[10px] font-bold text-slate-400">TOTAL</span></div>
|
||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-rose-500"></div><span className="text-[10px] font-bold text-slate-400">CRÍTICOS</span></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Donut Chart with Improved Legend */}
|
||||
<motion.div variants={itemVariants} className="bg-white dark:bg-[#1b1b1b] p-8 rounded-[32px] border border-slate-200 dark:border-white/5 shadow-xl shadow-slate-200/20 dark:shadow-none">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-xl"><PieIcon size={18} className="text-emerald-500" /></div>
|
||||
<h3 className="font-bold text-slate-800 dark:text-white uppercase tracking-widest text-[10px]">Distribuição por Empresa</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[280px] w-full flex flex-col sm:flex-row items-center">
|
||||
<div className="w-full sm:w-1/2 h-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={stats?.chartByCompany || []}
|
||||
innerRadius={75}
|
||||
outerRadius={95}
|
||||
paddingAngle={8}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{(stats?.chartByCompany || []).map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2 space-y-3 pl-4">
|
||||
{(stats?.chartByCompany || []).slice(0, 6).map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between group transition-all">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="min-w-2 h-2 rounded-full shadow-sm" style={{ backgroundColor: COLORS[i % COLORS.length] }}></div>
|
||||
<span className="text-[11px] font-bold text-slate-500 dark:text-slate-400 truncate group-hover:text-slate-900 dark:group-hover:text-white transition-colors uppercase tracking-tight">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<span className="text-[11px] font-black text-slate-800 dark:text-white">{item.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const MonthlySummaryGrid = React.memo(({ stats, months, onMonthClick }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-5">
|
||||
{months.map((mes, idx) => {
|
||||
const monthData = stats?.monthly ? stats.monthly[idx] : { count: 0, urgent: 0 };
|
||||
return (
|
||||
<motion.div
|
||||
key={idx}
|
||||
whileHover={{ y: -8, scale: 1.05 }}
|
||||
className="relative p-6 rounded-[28px] border border-slate-200 dark:border-white/5 bg-white dark:bg-[#1b1b1b] text-center shadow-sm hover:shadow-2xl hover:shadow-blue-500/10 hover:border-blue-400 dark:hover:border-blue-500/50 transition-all cursor-pointer group overflow-hidden"
|
||||
onClick={() => onMonthClick(idx + 1)}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-100 transition-opacity">
|
||||
<ArrowUpRight size={12} className="text-blue-500" />
|
||||
</div>
|
||||
<p className="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase tracking-[0.2em] group-hover:text-blue-500 transition-colors">{mes.substring(0, 3)}</p>
|
||||
<p className="text-4xl font-black text-slate-800 dark:text-white mt-2 group-hover:scale-110 transition-transform">{monthData.count}</p>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-slate-50 dark:border-white/2 flex flex-col items-center gap-1.5">
|
||||
{monthData.urgent > 0 ? (
|
||||
<Badge className="bg-rose-500/10 text-rose-500 border-none font-bold text-[8px] px-2 py-0.5 rounded-lg animate-pulse">
|
||||
{monthData.urgent} CRÍTICOS
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[8px] font-bold text-emerald-500 bg-emerald-500/5 px-2 py-0.5 rounded-lg">REGULAR</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const DetailModal = React.memo(({ open, onOpenChange, mesSelecionado, months, year, vacations }) => {
|
||||
const [modalSearch, setModalSearch] = useState('');
|
||||
const [modalStatusFilter, setModalStatusFilter] = useState('all');
|
||||
const [visibleCount, setVisibleCount] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVisibleCount(30);
|
||||
}
|
||||
}, [open, modalSearch, modalStatusFilter, mesSelecionado]);
|
||||
|
||||
const filteredForModal = useMemo(() => {
|
||||
if (!mesSelecionado) return [];
|
||||
return vacations.filter(v => {
|
||||
const limitMonth = v.limitDateObj.getMonth() + 1;
|
||||
const matchesMonth = limitMonth === mesSelecionado;
|
||||
const matchesSearch = v.name.toLowerCase().includes(modalSearch.toLowerCase());
|
||||
const matchesStatus = modalStatusFilter === 'all' ||
|
||||
(modalStatusFilter === 'urgent' && (v.limitDateObj - new Date()) < (60 * 24 * 60 * 60 * 1000));
|
||||
|
||||
return matchesMonth && matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [vacations, mesSelecionado, modalSearch, modalStatusFilter]);
|
||||
|
||||
const displayedVacations = useMemo(() => {
|
||||
return filteredForModal.slice(0, visibleCount);
|
||||
}, [filteredForModal, visibleCount]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setVisibleCount(prev => prev + 30);
|
||||
};
|
||||
|
||||
if (!mesSelecionado) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl bg-[#fafafa] dark:bg-[#0e0e0e] border-slate-200 dark:border-white/10 p-0 overflow-hidden flex flex-col max-h-[85vh] rounded-[32px] shadow-2xl">
|
||||
<DialogHeader className="p-10 bg-white dark:bg-white/2 border-b border-slate-100 dark:border-white/5 flex-shrink-0">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-4 bg-blue-500/10 rounded-[22px] text-blue-500 shadow-inner">
|
||||
<CalendarDays size={32} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-3xl font-black text-slate-800 dark:text-white tracking-tighter uppercase leading-none">
|
||||
{months[mesSelecionado - 1]} <span className="text-blue-500">{year}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400 font-bold text-xs uppercase tracking-widest pl-1">
|
||||
Férias com Vencimento no Período
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||
<Input
|
||||
placeholder="Pesquisar..."
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
className="pl-9 h-11 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/5 rounded-2xl text-[11px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-100 dark:bg-white/5 rounded-2xl">
|
||||
<button
|
||||
onClick={() => setModalStatusFilter('all')}
|
||||
className={`px-3 py-2 rounded-xl text-[10px] font-bold uppercase transition-all ${modalStatusFilter === 'all' ? 'bg-white dark:bg-slate-700 shadow-sm text-blue-500 dark:text-white' : 'text-slate-400'}`}
|
||||
>Todos</button>
|
||||
<button
|
||||
onClick={() => setModalStatusFilter('urgent')}
|
||||
className={`px-3 py-2 rounded-xl text-[10px] font-bold uppercase transition-all ${modalStatusFilter === 'urgent' ? 'bg-rose-500 text-white shadow-lg' : 'text-slate-400'}`}
|
||||
>Críticos</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-10 custom-scrollbar">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredForModal.length > 0 ? (
|
||||
<motion.div
|
||||
key="list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{displayedVacations.map((v, i) => (
|
||||
<motion.div
|
||||
key={v.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: (i % 30) * 0.05 }}
|
||||
whileHover={{ y: -5 }}
|
||||
className="bg-white dark:bg-[#1b1b1b] p-6 rounded-[28px] border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl transition-all group border-b-4 border-b-slate-100 dark:border-b-white/5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 rounded-[14px] border-2 border-slate-50 dark:border-white/5">
|
||||
<AvatarImage src={v.avatar} />
|
||||
<AvatarFallback className="bg-blue-500/10 text-blue-500 font-black text-xs">
|
||||
{v.name.substring(0,2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h5 className="font-black text-slate-800 dark:text-slate-100 text-xs uppercase leading-tight mb-0.5">{v.name}</h5>
|
||||
<p className="text-[9px] text-slate-400 font-bold uppercase tracking-widest">{v.companyName}</p>
|
||||
</div>
|
||||
</div>
|
||||
{(v.limitDateObj - new Date()) < (60 * 24 * 60 * 60 * 1000) && (
|
||||
<div className="p-1.5 bg-rose-500/10 rounded-lg"><AlertTriangle size={14} className="text-rose-500" /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-[10px] p-3 bg-slate-50 dark:bg-white/2 rounded-2xl">
|
||||
<div className="flex items-center gap-2 font-bold text-slate-400 uppercase tracking-tighter">
|
||||
<Briefcase size={12} className="text-slate-400" /> Vencimento
|
||||
</div>
|
||||
<span className="font-mono font-bold text-slate-700 dark:text-slate-200">{v.limitDate}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-500/5 rounded-2xl flex flex-col items-center justify-center gap-1.5 transition-colors group-hover:bg-blue-100 dark:group-hover:bg-blue-500/10">
|
||||
<span className="text-[9px] font-black text-blue-400 uppercase tracking-tighter">Saldo</span>
|
||||
<span className="text-xl font-black text-blue-600 dark:text-blue-400 leading-none">{v.diasAGozar}d</span>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-50 dark:bg-emerald-500/5 rounded-2xl flex flex-col items-center justify-center gap-1.5">
|
||||
<span className="text-[9px] font-black text-emerald-400 uppercase tracking-tighter">Direito</span>
|
||||
<span className="text-xl font-black text-emerald-600 dark:text-emerald-400 leading-none">{v.diasDireito}d</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(v.diasGozados / v.diasDireito) * 100}%` }}
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-black text-slate-400">{v.diasGozados} / {v.diasDireito}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full mt-6 py-3 rounded-2xl bg-slate-50 dark:bg-white/5 text-[9px] font-black text-slate-400 uppercase tracking-widest hover:bg-blue-600 hover:text-white transition-all transform active:scale-95 group-hover:border-blue-500/20 border border-transparent">
|
||||
Expandir Detalhes
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
{filteredForModal.length > visibleCount && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="col-span-1 md:col-span-2 lg:col-span-3 flex justify-center py-6"
|
||||
>
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
variant="outline"
|
||||
className="bg-white dark:bg-[#1b1b1b] hover:bg-slate-50 dark:hover:bg-white/5 text-slate-500 hover:text-blue-500 border border-slate-200 dark:border-white/10 shadow-sm font-bold uppercase tracking-widest text-[10px] px-8 py-6 rounded-2xl transition-all h-auto"
|
||||
>
|
||||
Carregar Mais ({filteredForModal.length - visibleCount} restantes)
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center py-20 text-center"
|
||||
>
|
||||
<div className="p-6 bg-slate-100 dark:bg-white/5 rounded-full mb-6">
|
||||
<Search size={40} className="text-slate-300" />
|
||||
</div>
|
||||
<h5 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Sem resultados encontrados</h5>
|
||||
<p className="text-sm text-slate-400 max-w-xs mx-auto">Tente ajustar seus filtros ou termos de pesquisa para encontrar o que procura.</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
export const GestaoFeriasView = () => {
|
||||
const {
|
||||
vacations,
|
||||
loading,
|
||||
filters,
|
||||
stats,
|
||||
setFilters,
|
||||
fetchVacations
|
||||
} = useGestaoFerias();
|
||||
|
||||
const [modalMesAberto, setModalMesAberto] = useState(false);
|
||||
const [mesSelecionado, setMesSelecionado] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVacations();
|
||||
}, [filters.month, filters.year, filters.department]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
field: 'name',
|
||||
header: 'Funcionário',
|
||||
width: 300,
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8 rounded-lg border border-slate-200 dark:border-white/10">
|
||||
<AvatarImage src={row.avatar} />
|
||||
<AvatarFallback className="bg-blue-500/10 text-blue-500 font-bold text-[10px]">
|
||||
{row.name.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-slate-800 dark:text-slate-100 text-[13px]">{row.name}</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-tight">{row.companyName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ field: 'admissionDate', header: 'Admissão', width: 110, className: 'font-mono text-xs' },
|
||||
{ field: 'lastReturnDate', header: 'Último Retorno', width: 110, className: 'font-mono text-xs' },
|
||||
{
|
||||
field: 'limitDate',
|
||||
header: 'Data Limite',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
const now = new Date();
|
||||
const isClose = (row.limitDateObj - now) < (60 * 24 * 60 * 60 * 1000);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-xs font-bold ${isClose ? 'text-rose-500 bg-rose-500/10 px-2 py-0.5 rounded-md' : 'text-slate-600 dark:text-slate-400'}`}>
|
||||
{row.limitDate}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ field: 'faltas', header: 'Faltas', width: 70, className: 'text-center font-bold text-xs' },
|
||||
{ field: 'diasAGozar', header: 'Saldo', width: 90, render: (row) => (
|
||||
<Badge variant="outline" className={`rounded-xl border-none font-bold text-[10px] ${row.diasAGozar > 0 ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'}`}>
|
||||
{row.diasAGozar} DIAS
|
||||
</Badge>
|
||||
)}
|
||||
], []);
|
||||
|
||||
const months = ["Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", "Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro"];
|
||||
|
||||
const handleMonthClick = (monthIdx) => {
|
||||
setMesSelecionado(monthIdx);
|
||||
setModalMesAberto(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="theme-rh flex-1 flex flex-col h-full w-full overflow-hidden bg-[#fafafa] dark:bg-[#0a0a0a] p-4 sm:p-6 lg:p-10 space-y-8">
|
||||
|
||||
{/* Header section with Animations */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex flex-col xl:flex-row xl:items-center justify-between gap-6"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-500/10 rounded-2xl shadow-inner border border-blue-500/5">
|
||||
<Calendar className="text-blue-500" size={28} />
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-slate-800 dark:text-white tracking-tighter leading-none">
|
||||
Gestão de <span className="text-blue-600">Férias</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-400 dark:text-slate-500 text-sm md:text-base font-medium italic opacity-80 pl-1">
|
||||
Controle centralizado de períodos aquisitivos e cronograma de gozo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-white dark:bg-white/5 p-2 rounded-[24px] border border-slate-200 dark:border-white/5 shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 border-r border-slate-200 dark:border-white/10">
|
||||
<CalendarDays size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={filters.month}
|
||||
onChange={(e) => setFilters({ month: parseInt(e.target.value) })}
|
||||
className="bg-transparent border-none text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300 focus:ring-0 cursor-pointer"
|
||||
>
|
||||
{months.map((m, i) => <option key={i} value={i+1}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
value={filters.year}
|
||||
onChange={(e) => setFilters({ year: parseInt(e.target.value) })}
|
||||
className="bg-transparent border-none text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300 focus:ring-0 px-3 cursor-pointer"
|
||||
>
|
||||
{[2024, 2025, 2026].map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
onClick={fetchVacations}
|
||||
disabled={loading}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-xl hover:bg-slate-100 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw size={16} className={`${loading ? 'animate-spin' : ''} text-blue-500`} />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex-1 overflow-y-auto custom-scrollbar space-y-8 pr-2"
|
||||
>
|
||||
{/* Dash Cards */}
|
||||
<KPISection stats={stats} vacations={vacations} />
|
||||
|
||||
{/* Charts Area */}
|
||||
<ChartsSection stats={stats} />
|
||||
|
||||
{/* Monthly Summary Grid - Styled like Calendars */}
|
||||
<motion.div variants={itemVariants} className="space-y-6">
|
||||
<div className="flex items-center gap-3 ml-2">
|
||||
<div className="p-2 bg-indigo-500/10 rounded-xl"><CalendarDays size={18} className="text-indigo-500" /></div>
|
||||
<h3 className="font-bold text-slate-800 dark:text-white uppercase tracking-widest text-[11px]">Resumo Mensal de Vencimentos</h3>
|
||||
</div>
|
||||
<MonthlySummaryGrid
|
||||
stats={stats}
|
||||
months={months}
|
||||
onMonthClick={handleMonthClick}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Table Detail with Animation */}
|
||||
<motion.div variants={itemVariants} className="bg-white dark:bg-[#1b1b1b] rounded-[32px] border border-slate-200 dark:border-white/5 overflow-hidden flex flex-col shadow-2xl shadow-slate-200/20 dark:shadow-none min-h-[600px]">
|
||||
<div className="p-8 border-b border-slate-200 dark:border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-indigo-500/5 rounded-xl"><Users className="text-indigo-500" size={18} /></div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 dark:text-white uppercase tracking-widest text-[10px]">Detalhamento Operacional</h3>
|
||||
<p className="text-[9px] text-slate-400 font-medium uppercase tracking-tighter mt-0.5">Visão tabular p/ exportação e filtros</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||
<Input placeholder="Buscar na tabela..." className="pl-9 h-10 border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-white/5 rounded-xl text-xs font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
<ExcelTable
|
||||
data={vacations}
|
||||
columns={columns}
|
||||
filterDefs={[]}
|
||||
pageSize={10}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Modal de Detalhes por Mês - Revamped with cards and Animations */}
|
||||
<DetailModal
|
||||
open={modalMesAberto}
|
||||
onOpenChange={setModalMesAberto}
|
||||
mesSelecionado={mesSelecionado}
|
||||
months={months}
|
||||
year={filters.year}
|
||||
vacations={vacations}
|
||||
/>
|
||||
|
||||
<RhFeedbackContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -184,12 +184,18 @@ export const boletosService = {
|
|||
* Rota: GET /financeiro/cliente/boletos
|
||||
* Resposta esperada: { cliente, dias_atraso, juros, multa, seuNumero, situacao, total_atualizado, valor_original, vencimento }
|
||||
* @param {number|string} idempresa
|
||||
* @param {Object} filters - { situacao, mes, ano }
|
||||
*/
|
||||
fetchJurosCliente: (idempresa) => handleRequest({
|
||||
fetchJurosCliente: (idempresa, filters = {}) => handleRequest({
|
||||
mockFn: () => simulateLatency([]),
|
||||
apiFn: async () => {
|
||||
// Filtra parâmetros vazios para não enviá-los na URL
|
||||
const cleanFilters = Object.fromEntries(
|
||||
Object.entries(filters).filter(([_, v]) => v !== null && v !== undefined && v !== '')
|
||||
);
|
||||
|
||||
const response = await api.get('/financeiro/cliente/boletos', {
|
||||
params: { idempresa }
|
||||
params: { idempresa, ...cleanFilters }
|
||||
});
|
||||
const raw = response?.data ?? response;
|
||||
const data = raw?.dados ?? raw?.Base_Dados_API ?? raw;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,11 @@ export const conciliacaoService = {
|
|||
apiFn: () => api.put('/beneficiario_pagador/inserir', { idextrato: id, beneficiario_pagador: beneficiary })
|
||||
}),
|
||||
|
||||
updateTransactionTag: (id, tag) => handleRequest({
|
||||
mockFn: () => simulateLatency({ success: true }),
|
||||
apiFn: () => api.post('/tag/inserir', { idextrato: id, tag: tag })
|
||||
}),
|
||||
|
||||
// ==================== NOVAS ROTAS ====================
|
||||
fetchRulesByCategory: (categoryId) => handleRequest({
|
||||
mockFn: () => simulateLatency(conciliacaoMock.rules.filter(r => String(r.categoria) === String(categoryId))),
|
||||
|
|
|
|||
|
|
@ -393,13 +393,14 @@ export const extratoService = {
|
|||
* Rota: POST /beneficiario_aplicado
|
||||
* Resposta esperada: { "categoria": "NOME DO CLIENTE", "linha_tempo": [...] }
|
||||
* Retorna: { categoria: string, linhaTemp: Array }
|
||||
* @param {string} beneficiario_pagador
|
||||
* @param {Object} data - { beneficiario_pagador, seuNumero }
|
||||
*/
|
||||
fetchBeneficiarioAplicado: (beneficiario_pagador) => handleRequest({
|
||||
fetchBeneficiarioAplicado: (data) => handleRequest({
|
||||
mockFn: () => simulateLatency({ categoria: '', linha_tempo: [] }),
|
||||
apiFn: async () => {
|
||||
const response = await api.post('/beneficiario_aplicado', {
|
||||
beneficiario_pagador: beneficiario_pagador
|
||||
beneficiario_pagador: data?.beneficiario_pagador,
|
||||
seuNumero: data?.seuNumero
|
||||
});
|
||||
const raw = response?.data ?? response;
|
||||
// A resposta é um objeto com "categoria" e "linha_tempo"
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ export const fornecedoresService = {
|
|||
|
||||
// Tenta primeiro a rota baseada no Resulte da lógica antiga
|
||||
const rotasPossiveis = [
|
||||
'/fornecedores',
|
||||
'/fornecedores/apresentar',
|
||||
'/status_fornecedores/apresentar',
|
||||
'/fornecedores'
|
||||
'/status_fornecedores/apresentar'
|
||||
];
|
||||
|
||||
for (const rota of rotasPossiveis) {
|
||||
|
|
|
|||
|
|
@ -77,14 +77,21 @@ export const workspaceConciliacaoService = {
|
|||
* Rota: GET /extrato/apresentar
|
||||
* @returns {Promise<TransacaoExtrato[]>}
|
||||
*/
|
||||
fetchExtrato: async () => {
|
||||
console.log('[workspaceConciliacaoService] fetchExtrato chamado');
|
||||
fetchExtrato: async (filters = {}) => {
|
||||
console.log('[workspaceConciliacaoService] fetchExtrato chamado com filtros:', filters);
|
||||
console.log('[workspaceConciliacaoService] USE_REAL_API:', USE_REAL_API);
|
||||
|
||||
if (USE_REAL_API) {
|
||||
try {
|
||||
console.log('[workspaceConciliacaoService] Chamando GET /extrato/apresentar');
|
||||
const response = await api.get('/extrato/apresentar');
|
||||
const params = new URLSearchParams();
|
||||
if (filters.mes) params.append('mes', filters.mes);
|
||||
if (filters.ano) params.append('ano', filters.ano);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/extrato/apresentar${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
console.log('[workspaceConciliacaoService] Chamando GET', url);
|
||||
const response = await api.get(url);
|
||||
console.log('[workspaceConciliacaoService] Resposta completa:', response);
|
||||
console.log('[workspaceConciliacaoService] response.data:', response.data);
|
||||
|
||||
|
|
@ -128,7 +135,8 @@ export const workspaceConciliacaoService = {
|
|||
desconto1: parseValorBackend(item.desconto1),
|
||||
desconto2: parseValorBackend(item.desconto2),
|
||||
desconto3: parseValorBackend(item.desconto3),
|
||||
valorTotal: parseValorBackend(item.valorTotal || item.valor)
|
||||
valorTotal: parseValorBackend(item.valorTotal || item.valor),
|
||||
tag: item.tag || ''
|
||||
}));
|
||||
console.log('[workspaceConciliacaoService] Dados mapeados:', mapped);
|
||||
return mapped;
|
||||
|
|
@ -148,7 +156,14 @@ export const workspaceConciliacaoService = {
|
|||
return handleRequest({
|
||||
mockFn: () => simulateLatency(MOCK_PENDING_TRANSACTIONS),
|
||||
apiFn: async () => {
|
||||
const response = await api.get('/extrato/apresentar');
|
||||
const params = new URLSearchParams();
|
||||
if (filters.mes) params.append('mes', filters.mes);
|
||||
if (filters.ano) params.append('ano', filters.ano);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/extrato/apresentar${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await api.get(url);
|
||||
const raw = response?.data ?? response;
|
||||
let data = raw?.dados ?? raw?.Base_Dados_API ?? raw;
|
||||
if (!Array.isArray(data)) {
|
||||
|
|
@ -165,7 +180,8 @@ export const workspaceConciliacaoService = {
|
|||
categoriaId: item.categoria && item.categoria !== '0' ? parseInt(item.categoria) : null,
|
||||
regraId: item.regra && item.regra !== '0' && item.regra !== 0 ? parseInt(item.regra) : null,
|
||||
beneficiario: item.beneficiario_pagador || null,
|
||||
status: item.categoria && item.categoria !== '0' && item.categoria !== 0 ? 'CONCILIADA' : 'PENDENTE'
|
||||
status: item.categoria && item.categoria !== '0' && item.categoria !== 0 ? 'CONCILIADA' : 'PENDENTE',
|
||||
tag: item.tag || ''
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
|
@ -566,7 +582,8 @@ export const workspaceConciliacaoService = {
|
|||
categoriaId: item.categoria && item.categoria !== '0' ? parseInt(item.categoria) : null,
|
||||
regraId: item.regra && item.regra !== '0' && item.regra !== 0 ? parseInt(item.regra) : null,
|
||||
beneficiario: item.beneficiario_pagador || item.beneficiario || null,
|
||||
status: 'CONCILIADA'
|
||||
status: 'CONCILIADA',
|
||||
tag: item.tag || ''
|
||||
}));
|
||||
console.log('[workspaceConciliacaoService] Dados mapeados:', mapped);
|
||||
return mapped;
|
||||
|
|
@ -627,7 +644,8 @@ export const workspaceConciliacaoService = {
|
|||
categoriaId: item.categoria && item.categoria !== '0' ? parseInt(item.categoria) : null,
|
||||
regraId: item.regra && item.regra !== '0' && item.regra !== 0 ? parseInt(item.regra) : null,
|
||||
beneficiario: item.beneficiario_pagador || item.beneficiario || null,
|
||||
status: 'CONCILIADA'
|
||||
status: 'CONCILIADA',
|
||||
tag: item.tag || ''
|
||||
}));
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export const workspaceDespesasV2Service = {
|
|||
* Busca todas as despesas (entradas planejadas)
|
||||
* GET /despesas/apresentar
|
||||
*/
|
||||
fetchDespesas: () => handleRequest({
|
||||
fetchDespesas: (filters = {}) => handleRequest({
|
||||
mockFn: () => simulateLatency([]),
|
||||
apiFn: async () => {
|
||||
const response = await api.get('/despesas/apresentar');
|
||||
const response = await api.get('/despesas/apresentar', { params: filters });
|
||||
const raw = response?.data;
|
||||
let list = [];
|
||||
|
||||
|
|
@ -39,6 +39,7 @@ export const workspaceDespesasV2Service = {
|
|||
categoria: item.categoria || '',
|
||||
descricao: item.descricao || '',
|
||||
metodoPagamento: item.metodoPagamento || item.pagoPorMeioDe || '',
|
||||
data_lancamento: item.data_lancamento || item.data_lacamento || '',
|
||||
...item
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -442,10 +442,10 @@ export const workspaceReceitasService = {
|
|||
}
|
||||
}),
|
||||
|
||||
fetchServicosMapeamentoCliente: (dominio) => handleRequest({
|
||||
fetchServicosMapeamentoCliente: (idempresa) => handleRequest({
|
||||
mockFn: () => simulateLatency([]),
|
||||
apiFn: async () => {
|
||||
const response = await api.post('/servicos/mapeamento', { empresa: dominio });
|
||||
const response = await api.post('/servicos/mapeamento', { idempresa: idempresa });
|
||||
const data = response?.data;
|
||||
const base = data?.Base_Dados_API ?? (Array.isArray(data) ? data : null);
|
||||
const first = Array.isArray(base) ? base[0] : base && typeof base === 'object' ? Object.values(base)[0] : null;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@ export const parseCurrency = (val) => {
|
|||
export const parseDateInfo = (dateStr) => {
|
||||
if (!dateStr) return { year: 0, month: 0, day: 0 };
|
||||
try {
|
||||
console.log('[dateUtils] Realizando parse de data bruta:', dateStr);
|
||||
// Tenta formato BR: DD/MM/YYYY HH:MM:SS ou DD/MM/YYYY
|
||||
if (typeof dateStr === 'string' && /^\d{2}\/\d{2}\/\d{4}/.test(dateStr)) {
|
||||
const datePart = dateStr.split(' ')[0];
|
||||
const [d, m, y] = datePart.split('/');
|
||||
return {
|
||||
year: Number(y),
|
||||
month: Number(m.replace(/^0+/, '')),
|
||||
day: Number(d.replace(/^0+/, ''))
|
||||
};
|
||||
}
|
||||
// Tenta formato ISO (YYYY-MM-DD)
|
||||
if (typeof dateStr === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dateStr)) {
|
||||
const [y, m, d] = dateStr.split('-');
|
||||
|
|
|
|||
Loading…
Reference in New Issue