Atualização 3 | Tratativa de erros no GR, Ajustes sobre o ambiente do Financeiro + Workspace

This commit is contained in:
daivid.alves 2026-02-20 11:26:06 -03:00
parent 5327a10251
commit 6d7ec7c9aa
44 changed files with 1743 additions and 1034 deletions

View File

@ -25,5 +25,10 @@
"feature": "Corre\u00e7\u00e3o nas rotas de edi\u00e7\u00e3o de Bases e Respons\u00e1veis do GR",
"status": "active",
"timestamp": "2026-02-08"
},
{
"feature": "Corrigido erro nos cards de Pend\u00eancias/Revis\u00e3o do GR para Despachantes",
"status": "active",
"timestamp": "2026-02-08"
}
]

40
refactor_gr_service.py Normal file
View File

@ -0,0 +1,40 @@
import re
import sys
def refactor_file(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Pattern to find:
# try {
# const response = await ...
# return ...
# } catch (error) {
# throw new Error(...) or throw error
# }
# This regex matches the try/catch block and replaces it with the inner code
# It accounts for indentation and multi-line content
pattern = r'try\s*\{([\s\S]*?)\}\s*catch\s*\(error\)\s*\{[\s\S]*?\}'
def replacement(match):
inner_code = match.group(1)
# Strip one level of indentation (usually 2 spaces)
lines = inner_code.split('\n')
new_lines = []
for line in lines:
if line.startswith(' '):
new_lines.append(line[2:])
elif line.startswith(' '):
new_lines.append(line[2:])
else:
new_lines.append(line)
return '\n'.join(new_lines).strip()
new_content = re.sub(pattern, replacement, content)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(new_content)
if __name__ == "__main__":
refactor_file(sys.argv[1])

View File

@ -24,6 +24,7 @@ export const AutoFillInput = ({
displayField = "name",
onSelect,
onAddNew,
addNewLabel = "Criar novo",
className,
icon: Icon = Search
}) => {
@ -160,7 +161,7 @@ export const AutoFillInput = ({
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs font-black uppercase tracking-tighter text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<Plus size={14} strokeWidth={3} />
Criar novo serviço: "{query}"
{addNewLabel}: "{query}"
</button>
</div>
</div>

View File

@ -87,9 +87,9 @@ export const BoletoCreationDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[95vh] h-fit md:h-auto overflow-hidden bg-[#0f172a] border-slate-800 text-white p-0 shadow-2xl z-[9999]">
<DialogContent className="max-w-4xl max-h-[95vh] h-fit md:h-auto overflow-hidden bg-white dark:bg-[#0f172a] border-slate-200 dark:border-slate-800 text-slate-900 dark:text-white p-0 shadow-2xl z-[9999]">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-white font-bold text-xl flex items-center gap-2">
<DialogTitle className="text-slate-900 dark:text-white font-bold text-xl flex items-center gap-2">
<Receipt className="w-6 h-6 text-blue-500" />
Gerar {creationMode === 'AVULSO' ? 'Boleto Avulso' : creationMode === 'MENSAL' ? 'Boleto Mensal' : 'Agendamento'}
</DialogTitle>
@ -113,7 +113,7 @@ export const BoletoCreationDialog = ({
type="date"
value={formData.data_vencimento}
onChange={(e) => setFormData({...formData, data_vencimento: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-12 text-lg text-center"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-12 text-lg text-center"
/>
</div>
</div>
@ -132,18 +132,18 @@ export const BoletoCreationDialog = ({
setShowsEmpresaSuggestions(true);
}}
onFocus={() => setShowsEmpresaSuggestions(true)}
className="bg-slate-800/50 border-slate-700 text-white pl-10 focus:border-blue-500 transition-all h-10 text-sm"
className="bg-white dark:bg-slate-800/50 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white pl-10 focus:border-blue-500 transition-all h-10 text-sm"
/>
{showsEmpresaSuggestions && filteredEmpresasSuggestions.length > 0 && (
<Card className="absolute z-[10000] w-full mt-1 bg-slate-800 border-slate-700 shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1">
<Card className="absolute z-[10000] w-full mt-1 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1">
<ScrollArea className="h-48">
{filteredEmpresasSuggestions.map((empresa, idx) => (
<div
key={idx}
className="p-3 hover:bg-slate-700 cursor-pointer border-b border-slate-700/50 last:border-0 transition-colors"
className="p-3 hover:bg-slate-50 dark:hover:bg-slate-700 cursor-pointer border-b border-slate-100 dark:border-slate-700/50 last:border-0 transition-colors"
onClick={() => handleSelectEmpresa(empresa)}
>
<p className="text-sm font-bold text-white">{empresa.nome_exibicao || empresa.nome}</p>
<p className="text-sm font-bold text-slate-900 dark:text-white">{empresa.nome_exibicao || empresa.nome}</p>
<p className="text-[10px] text-slate-400 uppercase font-medium">
{empresa.cpf_cnpj} {empresa.email}
</p>
@ -161,7 +161,7 @@ export const BoletoCreationDialog = ({
<Input
value={formData.nome}
onChange={(e) => setFormData({...formData, nome: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
<div className="space-y-1 col-span-1 md:col-span-3">
@ -170,7 +170,7 @@ export const BoletoCreationDialog = ({
type="email"
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
@ -180,7 +180,7 @@ export const BoletoCreationDialog = ({
<Input
value={formData.cpf_cnpj}
onChange={(e) => setFormData({...formData, cpf_cnpj: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
<div className="space-y-1 col-span-1 md:col-span-3">
@ -188,7 +188,7 @@ export const BoletoCreationDialog = ({
<Input
value={formData.telefone}
onChange={(e) => setFormData({...formData, telefone: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
@ -198,7 +198,7 @@ export const BoletoCreationDialog = ({
<Input
value={formData.endereco}
onChange={(e) => setFormData({...formData, endereco: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
<div className="space-y-1 col-span-1 md:col-span-2">
@ -206,7 +206,7 @@ export const BoletoCreationDialog = ({
<Input
value={formData.cep}
onChange={(e) => setFormData({...formData, cep: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
@ -215,7 +215,7 @@ export const BoletoCreationDialog = ({
<Input
value={formData.bairro}
onChange={(e) => setFormData({...formData, bairro: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
<div className="space-y-1 col-span-1 md:col-span-3">
@ -223,7 +223,7 @@ export const BoletoCreationDialog = ({
<Input
value={formData.cidade}
onChange={(e) => setFormData({...formData, city: e.target.value})}
className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm"
className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm"
/>
</div>
<div className="space-y-1 col-span-1 md:col-span-1">
@ -243,10 +243,10 @@ export const BoletoCreationDialog = ({
value={formData.tipo_pessoa}
onValueChange={(val) => setFormData({...formData, tipo_pessoa: val})}
>
<SelectTrigger className="bg-slate-800/30 border-slate-700 text-white h-9 text-sm">
<SelectTrigger className="bg-slate-100 dark:bg-slate-800/30 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1e293b] border-slate-700 text-white">
<SelectContent className="bg-white dark:bg-[#1e293b] border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white">
<SelectItem value="JURIDICA">Pessoa Jurídica</SelectItem>
<SelectItem value="FISICA">Pessoa Física</SelectItem>
</SelectContent>
@ -290,7 +290,7 @@ export const BoletoCreationDialog = ({
placeholder="0,00"
value={formData.valor_servico}
onChange={(e) => setFormData({...formData, valor_servico: e.target.value})}
className="bg-blue-500/10 border-blue-500/30 text-white text-2xl font-bold h-14 pl-12 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
className="bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-white text-2xl font-bold h-14 pl-12 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
</div>
@ -298,11 +298,11 @@ export const BoletoCreationDialog = ({
)}
</ScrollArea>
<DialogFooter className="p-6 pt-2 bg-slate-900/50 border-t border-slate-800 gap-3">
<DialogFooter className="p-6 pt-2 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 gap-3">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="text-slate-400 hover:text-white hover:bg-slate-800 h-10"
className="text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800 h-10"
>
Cancelar
</Button>

View File

@ -0,0 +1,47 @@
import React, { useState } from 'react';
import { Input } from '@/components/ui/input';
import { parseCurrency, formatCurrency, formatCurrencyForInput } from '../utils/dateUtils';
/**
* CurrencyInputV2 component for robust currency handling.
* Displays formatted currency (R$ 0,00) when blurred.
* Displays raw numeric format (0,00) when focused for easier editing.
*
* @param {Object} props - Component props
* @param {number|string} props.value - Numeric value
* @param {Function} props.onChange - Callback with the numeric value
* @param {string} [props.placeholder] - Input placeholder
* @param {string} [props.className] - Additional CSS classes
*/
export const CurrencyInputV2 = ({ value, onChange, placeholder = "R$ 0,00", className, ...props }) => {
const [focused, setFocused] = useState(false);
const [localValue, setLocalValue] = useState('');
const isEmpty = value === '' || value == null;
const displayValue = focused ? localValue : (isEmpty ? '' : formatCurrency(value));
const handleFocus = () => {
setFocused(true);
setLocalValue(isEmpty ? '' : formatCurrencyForInput(value));
};
const handleBlur = () => {
const numericValue = parseCurrency(localValue);
onChange(numericValue === '' ? '' : numericValue);
setFocused(false);
};
return (
<Input
{...props}
type="text"
inputMode="decimal"
className={className}
placeholder={placeholder}
value={displayValue}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={(e) => setLocalValue(e.target.value)}
/>
);
};

View File

@ -188,6 +188,17 @@ export const useContasReceber = (defaultView = 'default') => {
}, [defaultView]);
const [transactions, setTransactions] = useState([]);
const [cruzamentoLoading, setCruzamentoLoading] = useState(true);
const [clients, setClients] = useState([]);
const [services, setServices] = useState([]);
const [entradasPlanejadas, setEntradasPlanejadas] = useState([]);
const [boletos, setBoletos] = useState(MOCK_BOLETOS || []);
const [isLoadingServicos, setIsLoadingServicos] = useState(false);
const [isLoadingClients, setIsLoadingClients] = useState(false);
const [isLoadingEntradasPlanejadas, setIsLoadingEntradasPlanejadas] = useState(false);
const [isLoadingItens, setIsLoadingItens] = useState(false);
const [itensEntradaSelecionada, setItensEntradaSelecionada] = useState([]);
const [categorias, setCategorias] = useState([]);
const [caixas, setCaixas] = useState([]);
// Cruzamento: alimentado por /api/extrato/apresentar (tipoOperacao C). Planejado zerado.
useEffect(() => {
@ -243,19 +254,7 @@ export const useContasReceber = (defaultView = 'default') => {
return () => { cancelled = true; };
}, []);
const [clients, setClients] = useState([]);
const [services, setServices] = useState([]);
const [entradasPlanejadas, setEntradasPlanejadas] = useState([]);
const [boletos, setBoletos] = useState(MOCK_BOLETOS || []);
const [isLoadingServicos, setIsLoadingServicos] = useState(false);
const [isLoadingClients, setIsLoadingClients] = useState(false);
const [isLoadingEntradasPlanejadas, setIsLoadingEntradasPlanejadas] = useState(false);
const [isLoadingItens, setIsLoadingItens] = useState(false);
const [itensEntradaSelecionada, setItensEntradaSelecionada] = useState([]);
const [categorias, setCategorias] = useState([]);
const [caixas, setCaixas] = useState([]);
/** Carrega serviços do backend (GET /servicos/list ou /servicos/list?tipo=...). Mantém mock quando VITE_USE_MOCK=true. */
/** Carrega serviços do backend (GET /servicos/list ou /servicos/list?tipo=...). */
const loadServicos = useCallback(async (tipo) => {
setIsLoadingServicos(true);
try {
@ -271,10 +270,9 @@ export const useContasReceber = (defaultView = 'default') => {
} finally {
setIsLoadingServicos(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- toast omitido de propósito para evitar loop: callbacks estáveis
}, []);
}, [toast]);
/** Carrega clientes do backend (GET /empresas_financeiro). Mantém mock quando VITE_USE_MOCK=true. */
/** Carrega clientes do backend (GET /empresas_financeiro). */
const loadClients = useCallback(async () => {
setIsLoadingClients(true);
try {
@ -287,8 +285,7 @@ export const useContasReceber = (defaultView = 'default') => {
} finally {
setIsLoadingClients(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- toast omitido de propósito para evitar loop: callbacks estáveis
}, []);
}, [toast]);
/** Carrega entradas planejadas do backend (GET /empresas_planejadas). */
const loadEntradasPlanejadas = useCallback(async () => {
@ -303,8 +300,7 @@ export const useContasReceber = (defaultView = 'default') => {
} finally {
setIsLoadingEntradasPlanejadas(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- toast omitido de propósito para evitar loop: callbacks estáveis
}, []);
}, [toast]);
// Executa uma única vez no mount; ref evita reexecução mesmo com Strict Mode ou dependências instáveis
const didLoadReceitas = useRef(false);
@ -320,7 +316,7 @@ export const useContasReceber = (defaultView = 'default') => {
const totalRecebido = useMemo(() => {
if (!Array.isArray(transactions)) return 0;
return transactions
.filter(t => t?.status === 'Recebido' || t?.status === 'Liquidado')
.filter(t => t?.status === 'Recebido' || t?.status === 'Liquidado' || t?.status === 'Marcado como Recebido')
.reduce((acc, t) => acc + (t?.valor || 0), 0);
}, [transactions]);
@ -332,14 +328,14 @@ export const useContasReceber = (defaultView = 'default') => {
const totalGeral = totalRecebido + totalPendente;
// Actions para Serviços (API: /servicos/create, /servicos/edit, /servicos/delete)
const parseValor = (v) => {
// Actions para Serviços
const parseValor = useCallback((v) => {
if (v == null || v === '') return NaN;
const n = typeof v === 'number' ? v : Number(String(v).replace(',', '.'));
return Number.isFinite(n) ? n : NaN;
};
}, []);
const formatDataEnvio = (dateStr) => {
const formatDataEnvio = useCallback((dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return '';
@ -347,7 +343,7 @@ export const useContasReceber = (defaultView = 'default') => {
const m = d.getMonth() + 1;
const day = d.getDate();
return `${y}-${m}-${day}`;
};
}, []);
const createService = useCallback(async (serviceData) => {
if (!serviceData.servico?.trim()) {
@ -375,7 +371,7 @@ export const useContasReceber = (defaultView = 'default') => {
toast.error('Erro ao criar serviço. Tente novamente.');
return null;
}
}, [loadServicos, toast]);
}, [loadServicos, toast, parseValor]);
const updateService = useCallback(async (id, serviceData) => {
if (!serviceData.servico?.trim()) {
@ -404,7 +400,7 @@ export const useContasReceber = (defaultView = 'default') => {
toast.error('Erro ao editar serviço. Tente novamente.');
return null;
}
}, [loadServicos, toast]);
}, [loadServicos, toast, parseValor]);
const deleteService = useCallback(async (id) => {
try {
@ -420,8 +416,7 @@ export const useContasReceber = (defaultView = 'default') => {
}, [loadServicos, toast]);
// Actions para Clientes
const createClient = (clientData) => {
// Validação de campos obrigatórios
const createClient = useCallback(async (clientData) => {
const camposObrigatorios = [];
if (!clientData.nome?.trim()) camposObrigatorios.push('Nome');
if (!clientData.email?.trim()) camposObrigatorios.push('Email');
@ -432,17 +427,22 @@ export const useContasReceber = (defaultView = 'default') => {
return null;
}
const newClient = {
id: clients.length + 1,
...clientData
};
setClients(prev => [...prev, newClient]);
setIsLoadingClients(true);
try {
const response = await workspaceReceitasService.createClient(clientData);
await loadClients();
toast.success('Cliente criado com sucesso!', 'Sucesso');
return newClient;
};
return response;
} catch (err) {
console.warn('[useContasReceber] Erro ao criar cliente:', err);
toast.handleBackendError(err);
return null;
} finally {
setIsLoadingClients(false);
}
}, [loadClients, toast]);
const updateClient = (id, clientData) => {
// Validação de campos obrigatórios
const updateClient = useCallback(async (id, clientData) => {
const camposObrigatorios = [];
if (!clientData.nome?.trim()) camposObrigatorios.push('Nome');
if (!clientData.email?.trim()) camposObrigatorios.push('Email');
@ -450,17 +450,45 @@ export const useContasReceber = (defaultView = 'default') => {
if (camposObrigatorios.length > 0) {
toast.notifyFields(camposObrigatorios);
return;
return false;
}
setClients(prev => prev.map(c => c.id === id ? { ...c, ...clientData } : c));
setIsLoadingClients(true);
try {
await workspaceReceitasService.updateClient({ ...clientData, idempresa: id });
await loadClients();
toast.success('Cliente atualizado com sucesso!', 'Sucesso');
};
return true;
} catch (err) {
console.warn('[useContasReceber] Erro ao atualizar cliente:', err);
toast.handleBackendError(err);
return false;
} finally {
setIsLoadingClients(false);
}
}, [loadClients, toast]);
const deleteClient = (id) => {
setClients(prev => prev.filter(c => c.id !== id));
toast.success('Cliente excluído com sucesso!', 'Sucesso');
};
const updateClientStatus = useCallback(async (id, status) => {
setIsLoadingClients(true);
try {
await workspaceReceitasService.updateClientStatus({ idempresa: id, status, data_envio: formatDataEnvio(new Date()) });
await loadClients();
toast.success(`Status do cliente alterado para ${status}!`, 'Sucesso');
return true;
} catch (err) {
console.warn('[useContasReceber] Erro ao alterar status do cliente:', err);
toast.handleBackendError(err);
return false;
} finally {
setIsLoadingClients(false);
}
}, [loadClients, toast, formatDataEnvio]);
const deleteClient = useCallback(async (id) => {
// Nota: O service não tem deleteClient explicitamente, geralmente é feito via status Inativo
// Mas podemos implementar se houver rota. Como não vi rota de delete no service, usarei updateStatus
return updateClientStatus(id, 'Inativo');
}, [updateClientStatus]);
const loadItensEntradaPlanejada = useCallback(async (idEntrada) => {
if (!idEntrada) return;
@ -480,8 +508,6 @@ export const useContasReceber = (defaultView = 'default') => {
// Actions para Entradas Planejadas
const createEntradaPlanejada = useCallback(async (entradaData) => {
// Validação de campos obrigatórios
const camposObrigatorios = [];
if (!entradaData.dataEstimativa) camposObrigatorios.push('Data Estimativa');
if (!entradaData.cliente?.trim()) camposObrigatorios.push('Cliente');
@ -495,20 +521,13 @@ export const useContasReceber = (defaultView = 'default') => {
setIsLoadingEntradasPlanejadas(true);
try {
// Separar itens dos dados principais para envio sequencial
const { itens, ...parentData } = entradaData;
// 1. Criar a entrada principal
const response = await workspaceEntradasPlanejadasService.createEntradaPlanejada(parentData);
// Tenta obter o ID retornado pelo backend em diferentes formatos comuns
let idEntrada = response?.id || response?.idempresa || response?.identradas_planejadas || response?.Base_Dados_API?.[0]?.id || (response?.Base_Dados_API && response.Base_Dados_API.id);
// FALLBACK: Se o backend não retornar o ID (apenas uma mensagem), buscamos na listagem
if (!idEntrada) {
console.log('[useContasReceber] ID não retornado, consultando listagem para capturar ID...');
const list = await workspaceEntradasPlanejadasService.fetchEntradasPlanejadas();
// Tenta encontrar a entrada que acabamos de criar (pelo número de referência e cliente)
const matched = list.find(e =>
String(e.numeroReferencia) === String(parentData.numeroReferencia) &&
e.cliente === parentData.cliente
@ -517,13 +536,12 @@ export const useContasReceber = (defaultView = 'default') => {
}
if (!idEntrada) {
console.warn('[useContasReceber] ID da entrada não encontrado após tentativa de consulta:', response);
console.warn('[useContasReceber] ID da entrada não encontrado:', response);
await loadEntradasPlanejadas();
toast.error('Entrada criada, mas houve um problema ao vincular itens. Verifique o registro.');
toast.error('Entrada criada, mas houve um problema ao vincular itens.');
return false;
}
// 2. Criar os itens vinculados ao ID da entrada
const itemPromises = itens.map(item =>
workspaceEntradasPlanejadasService.createItemEntradaPlanejada({
...item,
@ -532,14 +550,12 @@ export const useContasReceber = (defaultView = 'default') => {
);
await Promise.all(itemPromises);
await loadEntradasPlanejadas();
toast.success('Entrada e itens criados com sucesso!', 'Sucesso');
return true;
} catch (err) {
console.warn('[useContasReceber] Erro ao criar entrada planejada:', err);
toast.error('Erro ao criar entrada planejada. Tente novamente.');
toast.error('Erro ao criar entrada planejada.');
return false;
} finally {
setIsLoadingEntradasPlanejadas(false);
@ -547,7 +563,6 @@ export const useContasReceber = (defaultView = 'default') => {
}, [loadEntradasPlanejadas, toast]);
const updateEntradaPlanejada = useCallback(async (id, entradaData) => {
// Validação de campos obrigatórios
const camposObrigatorios = [];
if (!entradaData.dataEstimativa) camposObrigatorios.push('Data Estimativa');
if (!entradaData.cliente?.trim()) camposObrigatorios.push('Cliente');
@ -563,22 +578,16 @@ export const useContasReceber = (defaultView = 'default') => {
try {
const { itens, ...parentData } = entradaData;
const idempresa = parentData.idempresa || id;
// 1. Atualizar a entrada principal
await workspaceEntradasPlanejadasService.updateEntradaPlanejada(idempresa, parentData);
// 2. Processar itens (Update ou Create)
const itemPromises = itens.map(item => {
const idItem = item.identradas_planejadas_itens || item.id;
// Se o item já tem ID e não é um ID temporário, atualiza
if (idItem && typeof idItem === 'number') {
return workspaceEntradasPlanejadasService.updateItemEntradaPlanejada(idItem, {
...item,
idEntrada: idempresa
});
} else {
// Se não tem ID, cria como novo item vinculado à entrada
return workspaceEntradasPlanejadasService.createItemEntradaPlanejada({
...item,
idEntrada: idempresa
@ -587,20 +596,18 @@ export const useContasReceber = (defaultView = 'default') => {
});
await Promise.all(itemPromises);
await loadEntradasPlanejadas();
toast.success('Entrada planejada atualizada com sucesso!', 'Sucesso');
return true;
} catch (err) {
console.warn('[useContasReceber] Erro ao atualizar entrada planejada:', err);
toast.error('Erro ao atualizar entrada planejada. Tente novamente.');
toast.error('Erro ao atualizar entrada planejada.');
return false;
} finally {
setIsLoadingEntradasPlanejadas(false);
}
}, [loadEntradasPlanejadas, toast]);
const deleteEntradaPlanejada = useCallback(async (id) => {
try {
await workspaceEntradasPlanejadasService.deleteEntradaPlanejada(id);
@ -609,30 +616,27 @@ export const useContasReceber = (defaultView = 'default') => {
return true;
} catch (err) {
console.warn('[useContasReceber] Erro ao excluir entrada planejada:', err);
toast.error('Erro ao excluir entrada planejada. Tente novamente.');
toast.error('Erro ao excluir entrada planejada.');
return null;
}
}, [loadEntradasPlanejadas, toast]);
// Actions para Boletos
const downloadBoleto = (boletoId) => {
// Implementar download
const downloadBoleto = useCallback((boletoId) => {
console.log('Download boleto:', boletoId);
};
}, []);
const sendBoleto = (boletoId) => {
// Implementar envio
const sendBoleto = useCallback((boletoId) => {
console.log('Enviar boleto:', boletoId);
};
}, []);
const scheduleBoleto = (boletoId, newDate) => {
const scheduleBoleto = useCallback((boletoId, newDate) => {
setBoletos(prev => prev.map(b => b.id === boletoId ? { ...b, dataVencimento: newDate } : b));
};
}, []);
const downloadFatura = (boletoId) => {
// Implementar download fatura
const downloadFatura = useCallback((boletoId) => {
console.log('Download fatura:', boletoId);
};
}, []);
const actions = useMemo(() => ({
setActiveSubView,
@ -646,6 +650,7 @@ export const useContasReceber = (defaultView = 'default') => {
// Clientes
createClient,
updateClient,
updateClientStatus,
deleteClient,
// Entradas Planejadas
createEntradaPlanejada,
@ -658,11 +663,10 @@ export const useContasReceber = (defaultView = 'default') => {
sendBoleto,
scheduleBoleto,
downloadFatura,
}), [
setActiveSubView, loadServicos, loadClients, loadEntradasPlanejadas,
createService, updateService, deleteService,
createClient, updateClient, deleteClient,
createClient, updateClient, updateClientStatus, deleteClient,
createEntradaPlanejada, updateEntradaPlanejada, deleteEntradaPlanejada,
loadItensEntradaPlanejada, setItensEntradaSelecionada,
downloadBoleto, sendBoleto, scheduleBoleto, downloadFatura

View File

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { extratoService } from '@/services/extratoService';
import { formatDateForChart } from '../utils/dateUtils';
import { boletosService } from '@/services/boletosService';
import { workspaceDespesasService } from '@/services/workspaceDespesasService';
import { workspaceSaldoService } from '@/services/workspaceSaldoService';
@ -30,6 +31,11 @@ export const useDashboard = () => {
boletosAbertos: []
});
// Estado para filtros de gráfico
const [jurosFilter, setJurosFilter] = useState('mensal'); // 'mensal' | 'anual'
const [fluxoPeriodo, setFluxoPeriodo] = useState('0'); // 0 = atual, 1 = 1 mês atrás, etc.
const [jurosData, setJurosData] = useState([]);
// Dados calculados para exibição
const [resumo, setResumo] = useState({
saldoTotal: 0,
@ -98,7 +104,7 @@ export const useDashboard = () => {
extratoService.fetchSaldo().catch(() => ({ disponivel: 0 })),
extratoService.fetchSaldoArmazenado().catch(() => []),
extratoService.fetchExtrato().catch(() => []),
extratoService.fetchFluxo().catch(() => ({ mensal: [] })),
extratoService.fetchFluxo().catch(() => ({ mensal: [], anual: [], diario: [] })),
boletosService.fetchBoletosStatus().catch(() => ({ cobrancas: [] })),
extratoService.fetchEmpresas().catch(() => []),
workspaceDespesasService.fetchContasAPagar().catch(() => []),
@ -249,38 +255,21 @@ export const useDashboard = () => {
const diario = data.fluxo?.diario || [];
const fluxoPorDia = {};
const hoje = new Date();
// Filtro: manter visualização do mês atual (como era antes)
// ou talvez mostrar os últimos 30 dias se o usuário quiser histórico recente?
// O título do card é "Histórico de Fluxo", e o subtítulo "Entradas vs Saídas".
// A implementação original filtrava pelo "mesAtualStr". Vou manter para não quebrar a expectativa de "visão do mês".
// ATUALIZAÇÃO: Se o endpoint traz histórico, pode ser interessante mostrar os últimos X dias
// se o mês estiver no começo. Mas para consistência com o resto do dashboard (que foca no mês),
// vou filtrar pelo mês atual OU se o array estiver vazio, fallback.
// Mas o pedido foi "alimentar o historico de fluxo como o fluxo diaria dessa rota".
// A rota retorna MUITOS dias. O gráfico de barras vai ficar ilegível se mostrar tudo.
// Vou filtrar pelo Mês Atual.
const mesAtualStr = formatMesAno(hoje);
// Calcular qual mês mostrar com base no fluxoPeriodo
const dataAlvo = new Date(hoje.getFullYear(), hoje.getMonth() - Number(fluxoPeriodo), 1);
const mesAlvoStr = `${dataAlvo.getFullYear()}-${String(dataAlvo.getMonth() + 1).padStart(2, '0')}`;
diario.forEach((item) => {
// O item tem structure: { data: "YYYY-MM-DD", tipoOperacao: "C"|"D", total: 123.45 }
if (!item.data) return;
// item.data vem como "YYYY-MM-DD"
// Vamos verificar se pertence ao mês atual
// A string "YYYY-MM-DD" começa com o ano-mes.
// formatMesAno retorna "YYYY-MM".
// Filtrar pelo mês alvo
if (!item.data.startsWith(mesAlvoStr)) return;
if (!item.data.startsWith(mesAtualStr)) return;
// item.data já está em YYYY-MM-DD.
// Para o gráfico precisamos de DD/MM
const [ano, mes, dia] = item.data.split('-');
const diaFormatado = `${dia}/${mes}`;
const diaFormatado = formatDateForChart(item.data);
if (!fluxoPorDia[diaFormatado]) {
fluxoPorDia[diaFormatado] = { name: diaFormatado, entradas: 0, saidas: 0 };
fluxoPorDia[diaFormatado] = { name: diaFormatado, entradas: 0, saidas: 0, rawDate: item.data };
}
const valor = safeNumber(item.total);
@ -291,18 +280,13 @@ export const useDashboard = () => {
}
});
// Converter para array e ordenar por data (dia)
// Converter para array e ordenar cronologicamente pela data bruta
return Object.values(fluxoPorDia)
.sort((a, b) => {
const [diaA, mesA] = a.name.split('/');
const [diaB, mesB] = b.name.split('/');
// Assumindo mesmo ano/mês, basta ordenar por dia
return Number(diaA) - Number(diaB);
});
.sort((a, b) => a.rawDate.localeCompare(b.rawDate));
};
const getBoletosPieData = () => {
const counts = {};
const values = {};
const mesAtual = formatMesAno(new Date());
data.boletos.cobrancas?.forEach((item) => {
@ -310,7 +294,8 @@ export const useDashboard = () => {
if (formatMesAno(new Date(c.dataVencimento)) !== mesAtual) return;
const st = c.situacao || 'OUTROS';
counts[st] = (counts[st] || 0) + 1;
const valor = safeNumber(c.valorNominal);
values[st] = (values[st] || 0) + valor;
});
const colors = {
@ -321,16 +306,14 @@ export const useDashboard = () => {
'OUTROS': '#94a3b8'
};
return Object.entries(counts).map(([name, value]) => ({
return Object.entries(values).map(([name, value]) => ({
name,
value,
color: colors[name] || colors['OUTROS']
}));
};
// Estado para filtro do gráfico de juros
const [jurosFilter, setJurosFilter] = useState('mensal'); // 'mensal' | 'anual'
const [jurosData, setJurosData] = useState([]);
// ... (previous code)
// ... (previous code)
@ -423,6 +406,8 @@ export const useDashboard = () => {
getJurosData,
jurosFilter,
setJurosFilter,
fluxoPeriodo,
setFluxoPeriodo,
getUltimasTransacoes,
fetchEntradasMes: async (mes, ano) => {
try {
@ -469,6 +454,14 @@ export const useDashboard = () => {
return [];
}
},
fetchJurosAdicionados: async (mes, ano) => {
try {
return await extratoService.fetchJurosAdicionados({ mes, ano });
} catch (err) {
console.error('Error fetching juros adicionados:', err);
return { dados: [], total_adicionado: 0, total_registros: 0 };
}
},
reload: loadData
};
};

View File

@ -37,7 +37,7 @@ export const useFornecedores = () => {
servico: item.servico || '',
valor: item.valor || 0,
obs: item.obs || '',
idregra: item.idregra || item.regra_id || null,
idregra: Array.isArray(item.idregra) ? item.idregra : (item.idregra ? [item.idregra] : (item.regra_id ? [item.regra_id] : [])),
regra_id: item.idregra || item.regra_id || null,
nome_regra: item.nome_regra || null,
// Campos opcionais para compatibilidade
@ -108,7 +108,7 @@ export const useFornecedores = () => {
servico: newFornecedor.servico || fornecedorData.servico || '',
valor: newFornecedor.valor || fornecedorData.valor || 0,
obs: newFornecedor.obs || fornecedorData.obs || '',
idregra: newFornecedor.idregra || newFornecedor.regra_id || fornecedorData.idregra || fornecedorData.regra_id || null,
idregra: Array.isArray(newFornecedor.idregra) ? newFornecedor.idregra : (newFornecedor.idregra ? [newFornecedor.idregra] : (fornecedorData.idregra ? (Array.isArray(fornecedorData.idregra) ? fornecedorData.idregra : [fornecedorData.idregra]) : [])),
regra_id: newFornecedor.idregra || newFornecedor.regra_id || fornecedorData.idregra || fornecedorData.regra_id || null,
status: 'Ativo',
moeda_padrao: 'BRL',
@ -170,7 +170,7 @@ export const useFornecedores = () => {
servico: updated.servico || fornecedorData.servico || '',
valor: updated.valor || fornecedorData.valor || 0,
obs: updated.obs || fornecedorData.obs || '',
idregra: updated.idregra || updated.regra_id || fornecedorData.idregra || fornecedorData.regra_id || null,
idregra: Array.isArray(updated.idregra) ? updated.idregra : (updated.idregra ? [updated.idregra] : (fornecedorData.idregra ? (Array.isArray(fornecedorData.idregra) ? fornecedorData.idregra : [fornecedorData.idregra]) : [])),
regra_id: updated.idregra || updated.regra_id || fornecedorData.idregra || fornecedorData.regra_id || null,
// Preserva campos existentes
status: fornecedorData.status || 'Ativo',

View File

@ -1,3 +1,4 @@
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
/**
@ -5,77 +6,56 @@ import { toast } from 'sonner';
* Usa sonner para exibir mensagens de sucesso, erro, warning e info
*/
export const useToast = () => {
return {
/**
* Exibe uma notificação de sucesso
* @param {string} message - Mensagem a ser exibida
* @param {string} title - Título opcional
*/
success: (message, title = 'Sucesso!') => {
const success = useCallback((message, title = 'Sucesso!') => {
toast.success(title, {
description: message,
duration: 4000,
});
},
}, []);
/**
* Exibe uma notificação de erro
* @param {string} message - Mensagem a ser exibida
* @param {string} title - Título opcional
*/
error: (message, title = 'Erro!') => {
const error = useCallback((message, title = 'Erro!') => {
toast.error(title, {
description: message,
duration: 5000,
});
},
}, []);
/**
* Exibe uma notificação de aviso
* @param {string} message - Mensagem a ser exibida
* @param {string} title - Título opcional
*/
warning: (message, title = 'Atenção!') => {
const warning = useCallback((message, title = 'Atenção!') => {
toast.warning(title, {
description: message,
duration: 4000,
});
},
}, []);
/**
* Exibe uma notificação informativa
* @param {string} message - Mensagem a ser exibida
* @param {string} title - Título opcional
*/
info: (message, title = 'Informação') => {
const info = useCallback((message, title = 'Informação') => {
toast.info(title, {
description: message,
duration: 4000,
});
},
}, []);
/**
* Exibe uma notificação de campos obrigatórios não preenchidos
* @param {string[]} fields - Lista de campos obrigatórios
*/
notifyFields: (fields) => {
const notifyFields = useCallback((fields) => {
toast.warning('Campos Obrigatórios', {
description: `Por favor, preencha: ${fields.join(', ')}`,
duration: 5000,
});
},
}, []);
/**
* Trata erros de backend de forma amigável
* @param {Error} error - Erro capturado
*/
handleBackendError: (error) => {
const handleBackendError = useCallback((error) => {
console.error(error);
const msg = error.response?.data?.message || error.message || 'Erro desconhecido na comunicação com o servidor.';
toast.error('Erro do Sistema', {
description: msg,
duration: 5000,
});
},
};
}, []);
return useMemo(() => ({
success,
error,
warning,
info,
notifyFields,
handleBackendError,
}), [success, error, warning, info, notifyFields, handleBackendError]);
};

View File

@ -14,6 +14,11 @@ export const formatDate = (dateString) => {
return `${day}/${month}/${year}`;
}
// Se a string já está no formato DD/MM/YYYY, retornar como está
if (typeof dateString === 'string' && /^\d{2}\/\d{2}\/\d{4}/.test(dateString)) {
return dateString;
}
// Se for objeto Date
if (dateString instanceof Date) {
const day = String(dateString.getDate()).padStart(2, '0');

View File

@ -60,6 +60,9 @@ import { useConciliacaoV2 } from '../hooks/useConciliacaoV2';
import { ExtratoCompletoView } from './conciliacao-v2/ExtratoCompletoView';
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
const XPICard = ({
title,
@ -135,11 +138,14 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
getJurosData,
jurosFilter,
setJurosFilter,
fluxoPeriodo,
setFluxoPeriodo,
getUltimasTransacoes,
fetchEntradasMes,
fetchSaidasPeriodo,
fetchTransacoesNaoConciliadas,
fetchBoletosAbertos,
fetchJurosAdicionados,
reload
} = useDashboard();
@ -150,24 +156,44 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
saldo: false,
despesas: false,
naoConciliadas: false,
boletosAbertos: false
boletosAbertos: false,
adicionado: false
});
const [modalData, setModalData] = useState({
saldo: [],
despesas: [],
naoConciliadas: [],
boletosAbertos: []
boletosAbertos: [],
adicionado: {
lista: [],
total_adicionado: 0,
total_registros: 0
}
});
const [modalLoading, setModalLoading] = useState(false);
const [modalDate, setModalDate] = useState(new Date());
// New states for juros detailing period selectors
const [jurosDetailPeriodo, setJurosDetailPeriodo] = useState('mes'); // 'mes' | 'ano'
const [jurosDetailMes, setJurosDetailMes] = useState(String(new Date().getMonth() + 1));
const [jurosDetailAno, setJurosDetailAno] = useState(String(new Date().getFullYear()));
// New states for requested adjustments
const [isExtratoPopupOpen, setIsExtratoPopupOpen] = useState(false);
const [isCategorizacaoOpen, setIsCategorizacaoOpen] = useState(false);
const fetchDataForModal = async (type, date) => {
const mes = date.getMonth() + 1;
const ano = date.getFullYear();
const fetchDataForModal = async (type, dateOrParams) => {
let mes, ano;
if (type === 'adicionado' && typeof dateOrParams === 'object') {
mes = dateOrParams.mes;
ano = dateOrParams.ano;
} else {
const d = dateOrParams instanceof Date ? dateOrParams : new Date();
mes = d.getMonth() + 1;
ano = d.getFullYear();
}
let data = [];
try {
@ -185,6 +211,14 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
case 'boletosAbertos':
data = await fetchBoletosAbertos(); // Global
break;
case 'adicionado':
const result = await fetchJurosAdicionados(mes, ano);
data = {
lista: result.dados || result.adicionado || [],
total_adicionado: result.total_adicionado || 0,
total_registros: result.total_registros || 0
};
break;
}
setModalData(prev => ({ ...prev, [type]: data }));
} catch (err) {
@ -197,8 +231,12 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
const openModal = (type) => {
setModalState(prev => ({ ...prev, [type]: true }));
const now = new Date();
setModalDate(now); // Reset to current month on open
if (type === 'adicionado') {
fetchDataForModal(type, { mes: jurosDetailMes, ano: jurosDetailAno });
} else {
setModalDate(now);
fetchDataForModal(type, now);
}
};
const handleModalDateChange = (type, newDate) => {
@ -252,7 +290,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
<h1 className="text-2xl font-bold text-slate-900 dark:text-white tracking-tight">Painel Financeiro</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 uppercase tracking-widest font-bold mt-1">Visão Geral do Workspace</p>
</div>
<div className="flex items-center gap-3">
{/* <div className="flex items-center gap-3">
<Button
variant="outline"
className="bg-white dark:bg-[#0f172a] border-slate-200 dark:border-slate-800 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-800 h-9 px-4 text-xs font-bold"
@ -268,7 +306,7 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
<DollarSign className="w-3.5 h-3.5 mr-2" />
Nova Receita
</Button>
</div>
</div> */}
</div>
{/* KPI Grid */}
@ -356,6 +394,13 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
<div className="p-2 bg-blue-500/10 rounded-lg">
<TrendingUp className="w-4 h-4 text-blue-500" />
</div>
<Button
variant="outline"
className="h-8 px-3 text-[10px] font-bold uppercase tracking-wider border-slate-200 dark:border-slate-700 bg-white dark:bg-[#0f172a] hover:bg-slate-50 dark:hover:bg-slate-800 text-slate-900 dark:text-white"
onClick={() => openModal('adicionado')}
>
Detalhamento
</Button>
</div>
</div>
</div>
@ -462,8 +507,21 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
<h3 className="text-lg font-bold text-slate-900 dark:text-white tracking-tight">Histórico de Fluxo</h3>
<p className="text-[10px] font-bold text-slate-500 dark:text-slate-500 uppercase tracking-widest">Entradas vs Saídas</p>
</div>
<div className="flex items-center gap-3">
<Select value={fluxoPeriodo} onValueChange={setFluxoPeriodo}>
<SelectTrigger className="w-[140px] h-8 text-xs font-bold uppercase tracking-wider bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-700">
<SelectValue placeholder="Período" />
</SelectTrigger>
<SelectContent className="z-[10000]">
<SelectItem value="0" className="text-xs font-bold uppercase">Mês Vigente</SelectItem>
<SelectItem value="1" className="text-xs font-bold uppercase">1 Mês Atrás</SelectItem>
<SelectItem value="2" className="text-xs font-bold uppercase">2 Meses Atrás</SelectItem>
<SelectItem value="3" className="text-xs font-bold uppercase">3 Meses Atrás</SelectItem>
</SelectContent>
</Select>
<Calendar className="w-4 h-4 text-slate-500" />
</div>
</div>
<div className="h-[280px]">
{loading ? (
@ -885,13 +943,116 @@ import { CategorizacaoDialog } from '../components/CategorizacaoDialog';
{formatCurrency(modalData.boletosAbertos.reduce((acc, curr) => acc + (Number(curr.cobranca?.valorNominal || curr.valorNominal || 0)), 0))}
</p>
</div>
<Button
{/* <Button
variant="outline"
className="border-slate-200 text-slate-700 h-10 px-6 font-bold uppercase text-[10px]"
onClick={() => onNavigate && onNavigate('receber/boletos-pendentes')}
>
Gerenciar Todos os Boletos
</Button>
</Button> */}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal - Detalhamento de Juros Adicionados */}
<Dialog open={modalState.adicionado} onOpenChange={(open) => setModalState(prev => ({ ...prev, adicionado: 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 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">
<TrendingUp className="text-blue-500" />
Juros Adicionados - {jurosDetailPeriodo === 'mes' ? `${MESES_REC[Number(jurosDetailMes)]} / ${jurosDetailAno}` : jurosDetailAno}
</DialogTitle>
<DialogDescription className="text-slate-500">
Detalhamento dos juros e acréscimos identificados no período.
</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>
<Select value={jurosDetailPeriodo} onValueChange={(val) => {
setJurosDetailPeriodo(val);
fetchDataForModal('adicionado', { mes: val === 'ano' ? null : jurosDetailMes, ano: jurosDetailAno });
}}>
<SelectTrigger className="h-8 w-[100px] bg-white dark:bg-slate-900/50 border-slate-200 dark:border-slate-800 text-xs font-semibold">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectItem value="mes">Mensal</SelectItem>
<SelectItem value="ano">Anual</SelectItem>
</SelectContent>
</Select>
{jurosDetailPeriodo === 'mes' && (
<Select value={jurosDetailMes} onValueChange={(val) => {
setJurosDetailMes(val);
fetchDataForModal('adicionado', { mes: val, ano: jurosDetailAno });
}}>
<SelectTrigger className="h-8 w-[120px] bg-white dark:bg-slate-900/50 border-slate-200 dark:border-slate-800 text-xs font-semibold">
<SelectValue placeholder="Mês" />
</SelectTrigger>
<SelectContent className="z-[10001]">
{MESES_REC.map((m, i) => (i === 0 ? null : <SelectItem key={i} value={String(i)}>{m}</SelectItem>))}
</SelectContent>
</Select>
)}
<Select value={jurosDetailAno} onValueChange={(val) => {
setJurosDetailAno(val);
fetchDataForModal('adicionado', { mes: jurosDetailPeriodo === 'ano' ? null : jurosDetailMes, ano: val });
}}>
<SelectTrigger className="h-8 w-[90px] bg-white dark:bg-slate-900/50 border-slate-200 dark:border-slate-800 text-xs font-semibold">
<SelectValue placeholder="Ano" />
</SelectTrigger>
<SelectContent className="z-[10001]">
{ANOS_REC.map((a) => <SelectItem key={a} value={String(a)}>{a}</SelectItem>)}
</SelectContent>
</Select>
</div>
</DialogHeader>
<div className="flex-1 h-[clamp(300px,60vh,600px)] overflow-auto custom-scrollbar">
<ExcelTable
data={modalData.adicionado.lista}
loading={modalLoading}
columns={[
{ header: 'Data', field: 'dataEntrada', width: 120, render: (row) => (
<span className="text-slate-500 font-medium">{formatDate(row.dataEntrada)}</span>
)},
{ header: 'Beneficiário/Pagador', field: 'beneficiario_pagador', width: 250, render: (row) => (
<span className="font-bold text-slate-900 dark:text-white">{row.beneficiario_pagador || 'N/A'}</span>
)},
{ header: 'Descrição', field: 'descricao', width: 300 },
{ header: 'Tipo', field: 'tipoTransacao', width: 120, render: (row) => (
<Badge variant="outline" className="text-[10px] font-bold uppercase tracking-widest bg-slate-100 dark:bg-slate-800 text-slate-500">
{row.tipoTransacao || 'GERAL'}
</Badge>
)},
{ header: 'Valor (Juros)', field: 'adicionado', width: 120, render: (row) => (
<span className="font-bold text-blue-600 dark:text-blue-400">{formatCurrency(row.adicionado)}</span>
)},
{ header: 'Valor Total', field: 'valor', width: 120, render: (row) => (
<span className="text-slate-500">{formatCurrency(row.valor)}</span>
)}
]}
/>
</div>
<DialogFooter className="bg-slate-50 dark:bg-slate-900/40 p-4 -mx-6 -mb-6 rounded-b-xl border-t border-slate-200 dark:border-slate-800 flex justify-between shrink-0">
<div className="text-left">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Total de Juros Adicionados</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{formatCurrency(modalData.adicionado.total_adicionado)}
</p>
<span className="text-xs text-slate-400 font-medium">({modalData.adicionado.total_registros} registros)</span>
</div>
</div>
{/* <Button
variant="outline"
className="border-slate-200 text-slate-700 h-10 px-6 font-bold uppercase text-[10px]"
onClick={() => setModalState(prev => ({ ...prev, adicionado: false }))}
>
Fechar
</Button> */}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -139,7 +139,7 @@ export const ConciliacaoView = ({ initialView }) => {
<div className="flex items-center justify-between gap-4 mb-4 sm:mb-6">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white tracking-tight flex items-center gap-2 sm:gap-3">
Conciliação
<Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-[10px] hidden sm:inline-flex">PREMIUM v2</Badge>
{/* <Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-[10px] hidden sm:inline-flex">PREMIUM v2</Badge> */}
</h1>
{/* <Button

View File

@ -93,7 +93,7 @@ export const ContasPagarView = () => {
<div className="flex items-center justify-between gap-4 mb-4 sm:mb-6">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white tracking-tight flex items-center gap-2 sm:gap-3">
Contas a Pagar
<Badge className="bg-rose-500/10 text-rose-500 border-rose-500/20 text-[10px] hidden sm:inline-flex">PREMIUM v2</Badge>
{/* <Badge className="bg-rose-500/10 text-rose-500 border-rose-500/20 text-[10px] hidden sm:inline-flex">PREMIUM v2</Badge> */}
</h1>
{/* <Button

View File

@ -44,7 +44,7 @@ import { formatCurrency, parseDateInfo, parseCurrency } from '@/utils/dateUtils'
/**
* Premium KPI Card com visualização clara
*/
const KPICard = ({ title, value, subtext, icon, colorClass, highlight, trend, percentual }) => {
const KPICard = ({ title, value, subtext, icon, colorClass, highlight, trend, percentual, loading }) => {
const Icon = icon;
return (
<Card className="bg-white dark:bg-slate-900/50 border-2 shadow-lg rounded-xl overflow-hidden group hover:scale-[1.02] transition-all">
@ -52,12 +52,16 @@ const KPICard = ({ title, value, subtext, icon, colorClass, highlight, trend, pe
<div className="flex items-start justify-between mb-[clamp(0.5rem,0.6vw,0.625rem)]">
<div className="flex-1 min-w-0">
<p className="text-[clamp(0.625rem,0.7vw,0.75rem)] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] mb-[clamp(0.25rem,0.4vw,0.375rem)]">{title}</p>
{loading ? (
<div className="h-[clamp(1.5rem,2vw,1.875rem)] w-32 bg-slate-200 dark:bg-slate-700 animate-pulse rounded mt-1" />
) : (
<h3 className={cn(
"text-[clamp(1.5rem,2vw,1.875rem)] font-semibold tracking-tight mb-[clamp(0.25rem,0.4vw,0.375rem)]",
highlight ? 'text-emerald-400 dark:text-emerald-400' : 'text-slate-900 dark:text-white'
highlight ? 'text-rose-400 dark:text-rose-400' : 'text-slate-900 dark:text-white'
)}>{value}</h3>
)}
<div className="flex items-center gap-[clamp(0.375rem,0.5vw,0.5rem)] mt-[clamp(0.375rem,0.5vw,0.5rem)] flex-wrap">
{trend && (
{trend && !loading && (
<span className={cn(
"text-[clamp(0.625rem,0.7vw,0.75rem)] font-semibold px-[clamp(0.375rem,0.4vw,0.5rem)] py-0.5 rounded shrink-0 flex items-center gap-[clamp(0.25rem,0.3vw,0.375rem)]",
trend === 'up' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' :
@ -97,6 +101,70 @@ export const CruzamentoDespesasView = ({ state }) => {
const [somaCategorias, setSomaCategorias] = React.useState([]);
const [isLoadingSoma, setIsLoadingSoma] = React.useState(false);
const [chartDataBackend, setChartDataBackend] = React.useState([]);
const [isLoadingChart, setIsLoadingChart] = React.useState(false);
const [totaisDespesas, setTotaisDespesas] = React.useState({
total_planejado: 0,
total_executado: 0,
total_geral: 0,
diferenca: 0,
taxa_realizacao: 0,
classificacao: '',
cor: ''
});
const [isLoadingTotais, setIsLoadingTotais] = React.useState(false);
// Efeito para buscar totais planejados do mês vigente
React.useEffect(() => {
const fetchTotais = async () => {
if (filtroTipo !== 'mes') return;
setIsLoadingTotais(true);
try {
const result = await extratoService.fetchDespesasResumo({
mes: filtroMes,
ano: filtroAno
});
if (result) {
setTotaisDespesas(result);
}
} catch (err) {
console.error('[CruzamentoDespesasView] Erro ao buscar totais:', err);
} finally {
setIsLoadingTotais(false);
}
};
fetchTotais();
}, [filtroMes, filtroAno, filtroTipo]);
// Efeito para buscar dados do gráfico do backend
React.useEffect(() => {
const fetchChartData = async () => {
setIsLoadingChart(true);
try {
const params = {
ano: filtroAno
};
if (filtroTipo === 'mes') {
params.mes = filtroMes;
}
const result = await extratoService.fetchDespesasGrafico(params);
const data = result?.grafico ?? result ?? [];
setChartDataBackend(Array.isArray(data) ? data : []);
} catch (err) {
console.error('[CruzamentoDespesasView] Erro ao buscar dados do gráfico:', err);
setChartDataBackend([]);
} finally {
setIsLoadingChart(false);
}
};
fetchChartData();
}, [filtroMes, filtroAno, filtroTipo]);
// Efeito para buscar soma por categoria do backend
React.useEffect(() => {
const fetchSoma = async () => {
@ -180,6 +248,31 @@ export const CruzamentoDespesasView = ({ state }) => {
}, [somaCategorias]);
const timelineData = useMemo(() => {
if (chartDataBackend.length > 0) {
return chartDataBackend.map(item => {
const variacao = (item.executado || 0) - (item.planejado || 0);
const percentual = (item.planejado || 0) > 0 ? (((item.executado || 0) / item.planejado) * 100).toFixed(1) : 0;
// Formata o nome do período para exibição
let name = String(item.periodo);
if (filtroTipo === 'ano') {
const mesesLabel = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const mesIdx = parseInt(item.periodo) - 1;
if (mesIdx >= 0 && mesIdx < 12) name = mesesLabel[mesIdx];
} else if (filtroTipo === 'mes') {
name = `${String(item.periodo).padStart(2, '0')}/${String(filtroMes).padStart(2, '0')}`;
}
return {
...item,
name,
variacao,
percentual,
value: item.executado
};
});
}
const mesesLabel = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
if (filtroTipo === 'ano') {
@ -237,7 +330,7 @@ export const CruzamentoDespesasView = ({ state }) => {
return Object.entries(map)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, values]) => ({ ...values, value: values.executado }));
}, [filteredPlanejadas, filteredExecutadas, filtroTipo, filtroAno]);
}, [filteredPlanejadas, filteredExecutadas, filtroTipo, filtroAno, chartDataBackend, filtroMes]);
const categoriaData = useMemo(() => {
return comparativoCategoria.map((item) => ({
@ -247,12 +340,40 @@ export const CruzamentoDespesasView = ({ state }) => {
}, [comparativoCategoria]);
const kpisCruzamento = useMemo(() => {
if (filtroTipo === 'mes' && totaisDespesas) {
const totalPlanejado = totaisDespesas.total_planejado || 0;
const totalExecutado = totaisDespesas.total_executado || 0;
const variacao = totaisDespesas.diferenca || 0;
const percentualVariacao = totaisDespesas.taxa_realizacao || 0;
const totalVolume = totaisDespesas.total_geral || (totalPlanejado + totalExecutado);
const classificacao = totaisDespesas.classificacao || '';
// Mapeamento de cores do back para Tailwind
const colorMap = {
'verde': 'text-emerald-500',
'azul': 'text-blue-500',
'amarelo': 'text-amber-500',
'vermelho': 'text-rose-500'
};
const corDinamica = colorMap[totaisDespesas.cor] || 'text-blue-500';
return { totalPlanejado, totalExecutado, variacao, percentualVariacao, totalVolume, classificacao, corDinamica };
}
const totalPlanejado = filteredPlanejadas.reduce((acc, item) => acc + (item.valor || 0), 0);
const totalExecutado = filteredExecutadas.reduce((acc, item) => acc + (item.valor || 0), 0);
const variacao = totalExecutado - totalPlanejado;
const percentualVariacao = totalPlanejado > 0 ? ((totalExecutado / totalPlanejado) * 100).toFixed(1) : 0;
return { totalPlanejado, totalExecutado, variacao, percentualVariacao };
}, [filteredPlanejadas, filteredExecutadas]);
return {
totalPlanejado,
totalExecutado,
variacao,
percentualVariacao,
totalVolume: totalPlanejado + totalExecutado,
classificacao: '',
corDinamica: 'text-blue-500'
};
}, [filteredPlanejadas, filteredExecutadas, filtroTipo, totaisDespesas]);
const COLORS = ['#ef4444', '#f97316', '#f59e0b', '#8b5cf6', '#64748b', '#10b981'];
@ -260,8 +381,10 @@ export const CruzamentoDespesasView = ({ state }) => {
const totalExecutado = kpisCruzamento.totalExecutado || 0;
const variacao = kpisCruzamento.variacao || 0;
const percentualVariacao = kpisCruzamento.percentualVariacao ?? 0;
const totalVolume = kpisCruzamento.totalVolume || 0;
const classificacao = kpisCruzamento.classificacao;
const corDinamica = kpisCruzamento.corDinamica;
const temPlanejado = totalPlanejado > 0;
const percentualDisplay = temPlanejado ? `${Number(percentualVariacao).toFixed(1)}%` : '—';
return (
<div className="space-y-4 sm:space-y-6 mt-4 sm:mt-6 relative pb-6 sm:pb-10">
@ -340,61 +463,45 @@ export const CruzamentoDespesasView = ({ state }) => {
</div>
{/* KPIs - Primeira Linha: APENAS Total Executado alimentado */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 md:gap-6 relative z-10">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-3 sm:gap-4 md:gap-6 relative z-10">
<KPICard
title="Total Planejado"
value={formatCurrency(totalPlanejado)}
subtext="Total esperado (despesas V2)"
icon={Target}
colorClass="text-slate-500"
loading={isLoadingTotais}
/>
<KPICard
title="Total Executado"
value={formatCurrency(totalExecutado)}
subtext="Total realizado (extrato)"
icon={CheckCircle2}
colorClass="text-emerald-500"
colorClass="text-rose-500"
highlight
loading={isLoadingTotais}
/>
<KPICard
title="Volume Geral"
value={formatCurrency(totalPlanejado + totalExecutado)}
subtext="Soma de planejado + executado"
icon={TrendingUp}
colorClass="text-purple-500"
/>
</div>
{/* KPIs - Segunda Linha: TODOS ZERADOS */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 relative z-10">
{/* KPIs de Variância */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-3 sm:gap-4 md:gap-6 relative z-10">
<KPICard
title="Variação"
value={formatCurrency(variacao)}
subtext={variacao >= 0 ? "Aumento vs planejado" : "Economia vs planejado"}
subtext={variacao >= 0 ? "Fora do planejado" : "Dentro do planejado"}
icon={BarChart3}
trend={variacao > 0 ? 'up' : 'down'}
trend={variacao > 0 ? 'down' : 'up'}
colorClass={variacao > 0 ? "text-rose-500" : "text-emerald-500"}
loading={isLoadingTotais}
/>
<KPICard
title="Taxa de Realização"
title="Percentual de diferença"
value={`${percentualVariacao}%`}
subtext="Percentual do planejado executado"
subtext={classificacao || "Percentual do planejado executado"}
icon={Activity}
colorClass="text-blue-500"
/>
<KPICard
title="Economia"
value={variacao < 0 ? formatCurrency(Math.abs(variacao)) : "R$ 0,00"}
subtext="Valor abaixo do planejado"
icon={TrendingDown}
colorClass="text-emerald-500"
/>
<KPICard
title="Excesso"
value={variacao > 0 ? formatCurrency(variacao) : "R$ 0,00"}
subtext="Valor acima do planejado"
icon={AlertCircle}
colorClass="text-rose-500"
colorClass={corDinamica}
loading={isLoadingTotais}
/>
</div>

View File

@ -27,6 +27,7 @@ import { cn } from '@/lib/utils';
import ExcelTable from '../../components/ExcelTable';
import { formatDate, formatCurrency } from '../../utils/dateUtils';
import { AutoFillInput } from '@/components/shared/AutoFillInput';
import { CurrencyInputV2 } from '../../components/CurrencyInputV2';
const METODOS_PAGAMENTO = [
{ value: 'pix', label: 'PIX' },
@ -78,7 +79,7 @@ export const DespesasView = () => {
pagoPorMeioDe: '',
nomeCliente: '',
status: 'PENDENTE',
montante: 0,
montante: '',
categoria: '',
descricao: '',
metodoPagamento: ''
@ -124,7 +125,7 @@ export const DespesasView = () => {
pagoPorMeioDe: '',
nomeCliente: '',
status: 'PENDENTE',
montante: 0,
montante: '',
categoria: '',
descricao: '',
metodoPagamento: ''
@ -497,7 +498,7 @@ export const DespesasView = () => {
</div>
{/* Upload de Recibos */}
<div>
{/* <div>
<Label className="text-sm font-bold text-slate-900 dark:text-white mb-3 block">Recibos e Documentos</Label>
<div className="border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-xl p-8 text-center bg-slate-50 dark:bg-slate-900/20 group hover:border-rose-400 transition-colors cursor-pointer">
<Upload className="w-10 h-10 mx-auto mb-4 text-slate-300 group-hover:text-rose-500 transition-colors" />
@ -505,7 +506,7 @@ export const DespesasView = () => {
<p className="text-xs text-slate-500 mb-4">PNG, JPG ou PDF até 10MB</p>
<Button size="sm" variant="outline" className="h-8 border-slate-300">Selecionar arquivo</Button>
</div>
</div>
</div> */}
</div>
</CardContent>
</Card>
@ -609,17 +610,12 @@ export const DespesasView = () => {
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Montante (R$) *</Label>
<div className="relative group">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-black text-sm z-10 pointer-events-none group-focus-within:text-rose-500 transition-colors">R$</span>
<Input
type="number"
step="0.01"
<CurrencyInputV2
value={formData.montante}
onChange={(e) => setFormData({ ...formData, montante: parseFloat(e.target.value) || 0 })}
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl pl-12 pr-4 font-mono font-bold text-rose-600 focus:ring-rose-500/10 focus:border-rose-500 relative"
onChange={(val) => setFormData({ ...formData, montante: val || 0 })}
className="h-11 bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 rounded-xl px-4 font-mono font-bold text-rose-600 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">Status do Registro *</Label>
@ -706,21 +702,17 @@ export const DespesasView = () => {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Débito (R$)</Label>
<Input
type="number"
step="0.01"
<CurrencyInputV2
value={itemFormData.debito}
onChange={(e) => setItemFormData({ ...itemFormData, debito: parseFloat(e.target.value) || 0 })}
onChange={(val) => setItemFormData({ ...itemFormData, debito: val || 0 })}
className="h-11 bg-slate-50/50 border-slate-200 dark:bg-slate-900/50 dark:border-slate-800 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/10 transition-all rounded-xl font-mono text-emerald-600 font-bold text-center"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Crédito (R$)</Label>
<Input
type="number"
step="0.01"
<CurrencyInputV2
value={itemFormData.credito}
onChange={(e) => setItemFormData({ ...itemFormData, credito: parseFloat(e.target.value) || 0 })}
onChange={(val) => setItemFormData({ ...itemFormData, credito: val || 0 })}
className="h-11 bg-slate-50/50 border-slate-200 dark:bg-slate-900/50 dark:border-slate-800 focus:border-rose-500 focus:ring-2 focus:ring-rose-500/10 transition-all rounded-xl font-mono text-rose-600 font-bold text-center"
/>
</div>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Building2,
Plus,
@ -87,6 +87,7 @@ export const FornecedoresView = () => {
const [loadingExtrato, setLoadingExtrato] = useState(false);
const [tipoOperacao, setTipoOperacao] = useState('D'); // D para Débito (padrão fornecedores)
const [serviceType, setServiceType] = useState('servico'); // 'servico' | 'produto'
const [availableServices, setAvailableServices] = useState([]);
const [availableRules, setAvailableRules] = useState([]); // Regras/Categorias para vínculo
@ -99,7 +100,7 @@ export const FornecedoresView = () => {
cpf_cnpj: '',
tipo_pessoa: 'JURIDICA',
servicos: [], // Mantido como array localmente
idregra: '', // ID da Regra vinculada
regras: [], // Lista de IDs de regras vinculadas
obs: '',
// Campos opcionais para compatibilidade
endereco: '',
@ -131,7 +132,7 @@ export const FornecedoresView = () => {
const fetchData = async () => {
try {
const [services, rulesResponse] = await Promise.all([
workspaceReceitasService.fetchServicos(),
workspaceReceitasService.fetchServicos(serviceType),
conciliacaoService.fetchRules() // Buscando Regras para usar como Regra de Conciliação
]);
@ -148,7 +149,7 @@ export const FornecedoresView = () => {
}
};
fetchData();
}, []);
}, [serviceType]);
// Fecth Extrato effect
useEffect(() => {
@ -158,17 +159,19 @@ export const FornecedoresView = () => {
}
const fetchExtratoData = async () => {
const regraId = selectedFornecedor.idregra || selectedFornecedor.regra_id;
const regraIds = Array.isArray(selectedFornecedor.idregra)
? selectedFornecedor.idregra.filter(Boolean)
: (selectedFornecedor.idregra ? [selectedFornecedor.idregra] : (selectedFornecedor.regra_id ? [selectedFornecedor.regra_id] : []));
if (!regraId) {
if (regraIds.length === 0) {
setProviderExtrato([]);
return;
}
setLoadingExtrato(true);
try {
// Usando a nova rota /regra_aplicada via hook
const data = await actions.fetchRegraAplicada(regraId, tipoOperacao);
// Passa a lista de IDs de regra; idempresa pode ser passado se disponível no futuro
const data = await actions.fetchRegraAplicada(regraIds, tipoOperacao);
setProviderExtrato(data || []);
} catch (error) {
console.error('Erro ao buscar extrato por regra:', error);
@ -235,17 +238,20 @@ export const FornecedoresView = () => {
nome: fornecedor.nome || '',
nome_exibicao: fornecedor.nome_exibicao || fornecedor.nome || '',
email: fornecedor.email || '',
telefone: fornecedor.telefone || '',
cpf_cnpj: fornecedor.cpf_cnpj || '',
telefone: maskPhone(fornecedor.telefone || ''),
cpf_cnpj: maskCPFCNPJ(fornecedor.cpf_cnpj || ''),
tipo_pessoa: fornecedor.tipo_pessoa || 'JURIDICA',
servicos: parsedServicos,
idregra: fornecedor.idregra || fornecedor.regra_id || fornecedor.categoria_id || '', // Mapeia idregra ou regra_id
// Normaliza regras como array de IDs (number)
regras: Array.isArray(fornecedor.idregra)
? fornecedor.idregra.map(Number)
: (fornecedor.idregra ? [Number(fornecedor.idregra)] : (fornecedor.regra_id ? [Number(fornecedor.regra_id)] : [])),
obs: fornecedor.obs || '',
endereco: fornecedor.endereco || '',
bairro: fornecedor.bairro || '',
cidade: fornecedor.cidade || '',
uf: fornecedor.uf || '',
cep: fornecedor.cep || '',
cep: maskCEP(fornecedor.cep || ''),
status: fornecedor.status || 'Ativo',
moeda_padrao: fornecedor.moeda_padrao || 'BRL',
periodo_vencimento: fornecedor.periodo_vencimento || 'Pagar no recebimento'
@ -261,7 +267,7 @@ export const FornecedoresView = () => {
cpf_cnpj: '',
tipo_pessoa: 'JURIDICA',
servicos: [],
idregra: '',
regras: [],
valor: '',
obs: '',
endereco: '',
@ -292,8 +298,8 @@ export const FornecedoresView = () => {
telefone: cleanTelefone,
cpf_cnpj: cleanCPFCNPJ,
tipo_pessoa: formData.tipo_pessoa || 'JURIDICA',
servico: formData.servicos, // Envia como lista conforme exigido pelo backend
idregra: formData.idregra ? Number(formData.idregra) : null, // Novo campo de regra
servico: formData.servicos, // Envia como lista
idregra: formData.regras.map(Number), // Envia lista de IDs de regras
obs: formData.obs || '',
// Campos opcionais
status: formData.status || 'Ativo',
@ -653,14 +659,23 @@ export const FornecedoresView = () => {
</div>
</div>
<div className="bg-slate-50 dark:bg-slate-800/30 p-4 rounded-xl border border-slate-200/60 dark:border-slate-800/50 sm:col-span-2">
<Label className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1.5 block">Regra de Conciliação</Label>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-rose-50/50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200/50 dark:border-rose-500/20 px-2 py-0.5 text-[11px] font-bold">
{getRuleName(selectedFornecedor.regra_id || selectedFornecedor.idregra)}
<Label className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1.5 block">Regras de Conciliação</Label>
<div className="flex flex-wrap items-center gap-2">
{(() => {
const regraIds = Array.isArray(selectedFornecedor.idregra)
? selectedFornecedor.idregra
: (selectedFornecedor.idregra ? [selectedFornecedor.idregra] : (selectedFornecedor.regra_id ? [selectedFornecedor.regra_id] : []));
if (regraIds.length === 0) {
return <p className="text-xs text-slate-400 italic">Nenhuma regra vinculada</p>;
}
return regraIds.map((rid, idx) => (
<Badge key={idx} variant="outline" className="bg-rose-50/50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200/50 dark:border-rose-500/20 px-2 py-0.5 text-[11px] font-bold">
{getRuleName(rid)} <span className="text-[10px] text-slate-400 font-normal ml-1">(#{rid})</span>
</Badge>
<span className="text-[10px] text-slate-500 italic">
(ID: {selectedFornecedor.regra_id || selectedFornecedor.idregra || 'N/D'})
</span>
));
})()}
</div>
</div>
</div>
@ -1150,11 +1165,49 @@ export const FornecedoresView = () => {
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs font-bold text-slate-700 dark:text-slate-300">Serviços Oferecidos</Label>
{/* Input simples para atribuir serviços */}
<div className="flex gap-2">
<Input
id="servico-input"
placeholder="Nome do serviço..."
className="bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = e.target.value.trim();
if (val && !formData.servicos.includes(val)) {
setFormData({ ...formData, servicos: [...formData.servicos, val] });
toast.success(`Serviço "${val}" atribuído!`);
}
e.target.value = '';
}
}}
/>
<Button
type="button"
size="sm"
className="bg-rose-600 hover:bg-rose-700 text-white shrink-0"
onClick={() => {
const input = document.getElementById('servico-input');
const val = input?.value?.trim();
if (val && !formData.servicos.includes(val)) {
setFormData({ ...formData, servicos: [...formData.servicos, val] });
toast.success(`Serviço "${val}" atribuído!`);
}
if (input) input.value = '';
}}
>
<Plus className="w-4 h-4" />
</Button>
</div>
{/* [AutoFillInput comentado para reutilização futura]
<AutoFillInput
placeholder="Pesquisar e adicionar serviços..."
placeholder={`Pesquisar ${serviceType === 'servico' ? 'serviços' : 'produtos'}...`}
data={availableServices}
filterField="servico"
displayField="servico"
addNewLabel="ATRIBUIR NOVO SERVIÇO"
onSelect={(selectedSvc) => {
if (selectedSvc && !formData.servicos.includes(selectedSvc.servico)) {
setFormData({
@ -1174,6 +1227,7 @@ export const FornecedoresView = () => {
}}
className="bg-white dark:bg-slate-800"
/>
*/}
{/* Lista de serviços selecionados */}
<div className="flex flex-wrap gap-2 mt-2">
@ -1202,32 +1256,70 @@ export const FornecedoresView = () => {
</div>
</div>
{/* Regra Vinculada */}
{/* Regras de Conciliação - Multi-seleção */}
<div>
<Label className="text-xs font-bold text-slate-700 dark:text-slate-300">
Regra de Conciliação
Regras de Conciliação
</Label>
<div className="flex gap-2 mt-1">
<Select
value={String(formData.idregra ?? '')}
onValueChange={(val) => setFormData({ ...formData, idregra: val === 'no-rule' ? '' : val })}
value=""
onValueChange={(val) => {
if (val && val !== 'no-rule') {
const numVal = Number(val);
if (!formData.regras.includes(numVal)) {
setFormData({ ...formData, regras: [...formData.regras, numVal] });
}
}
}}
>
<SelectTrigger className="mt-1 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
<SelectValue placeholder="Selecione uma regra..." />
<SelectTrigger className="bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 flex-1">
<SelectValue placeholder="Adicionar regra..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="no-rule">Sem regra vinculada</SelectItem>
{availableRules.map((rule, index) => (
{availableRules
.filter(rule => !formData.regras.includes(Number(rule.id ?? rule.idregras_financeiro)))
.map((rule, index) => (
<SelectItem key={String(rule.id ?? rule.idregras_financeiro ?? index)} value={String(rule.id ?? rule.idregras_financeiro ?? '')}>
{rule.regra || rule.nome}
</SelectItem>
))}
))
}
</SelectContent>
</Select>
</div>
{/* Regras selecionadas como badges */}
<div className="flex flex-wrap gap-2 mt-2">
{formData.regras.map((regraId, idx) => {
const ruleObj = availableRules.find(r => Number(r.id ?? r.idregras_financeiro) === regraId);
return (
<Badge
key={idx}
className="bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/20 px-2 py-1 flex items-center gap-1.5"
>
{ruleObj ? (ruleObj.regra || ruleObj.nome) : `Regra #${regraId}`}
<button
onClick={() => setFormData({
...formData,
regras: formData.regras.filter(r => r !== regraId)
})}
className="text-rose-400 hover:text-rose-600 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</Badge>
);
})}
{formData.regras.length === 0 && (
<p className="text-[10px] text-slate-500 italic">Nenhuma regra selecionada</p>
)}
</div>
<p className="text-[10px] text-slate-500 mt-1">
Define a regra automática para transações deste fornecedor.
Define as regras automáticas para transações deste fornecedor.
</p>
</div>
<div>
<Label className="text-xs font-bold text-slate-700 dark:text-slate-300">Observações</Label>
<Input

View File

@ -873,9 +873,9 @@ export const BoletosView = ({ initialFilter = 'TODOS' }) => {
{/* Dialog de Agendamento */}
<Dialog open={isScheduleDialogOpen} onOpenChange={setIsScheduleDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-800 text-white">
<DialogContent className="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-900 dark:text-white">
<DialogHeader>
<DialogTitle className="text-white font-bold text-lg">
<DialogTitle className="text-slate-900 dark:text-white font-bold text-lg">
Agendar Boleto
</DialogTitle>
<div className="sr-only">Formulário para agendar o vencimento do boleto selecionado</div>
@ -884,22 +884,22 @@ export const BoletosView = ({ initialFilter = 'TODOS' }) => {
{selectedBoleto && (
<div className="space-y-2">
<div>
<Label className="text-slate-300 text-xs font-bold uppercase block mb-2">
<Label className="text-slate-500 dark:text-slate-300 text-xs font-bold uppercase block mb-2">
Boleto: {selectedBoleto.numero || selectedBoleto.id}
</Label>
<Label className="text-slate-300 text-xs font-bold uppercase block mb-2">
<Label className="text-slate-500 dark:text-slate-300 text-xs font-bold uppercase block mb-2">
Pagador: {selectedBoleto.pagador || selectedBoleto.cliente}
</Label>
</div>
<div className="space-y-2">
<Label className="text-slate-300 text-xs font-bold uppercase">
<Label className="text-slate-500 dark:text-slate-300 text-xs font-bold uppercase">
Nova Data de Vencimento
</Label>
<Input
type="date"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
className="bg-slate-800 border-slate-700 text-white focus:border-orange-500"
className="bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white focus:border-orange-500"
/>
</div>
</div>
@ -909,7 +909,7 @@ export const BoletosView = ({ initialFilter = 'TODOS' }) => {
<Button
variant="ghost"
onClick={() => setIsScheduleDialogOpen(false)}
className="text-slate-400 hover:text-white"
className="text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
Cancelar
</Button>
@ -1169,28 +1169,28 @@ export const BoletosView = ({ initialFilter = 'TODOS' }) => {
function CancelamentoDialog({ open, onOpenChange, onConfirm, loading, reason, setReason, boleto }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-800 text-white max-w-md z-[1000]">
<DialogContent className="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-900 dark:text-white max-w-md z-[1000]">
<DialogHeader>
<DialogTitle className="text-white font-bold text-lg flex items-center gap-2">
<DialogTitle className="text-slate-900 dark:text-white font-bold text-lg flex items-center gap-2">
<XCircle className="w-5 h-5 text-red-500" />
Cancelar Boleto
</DialogTitle>
<div className="sr-only">Confirmação de cancelamento de boleto</div>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div className="bg-red-50 dark:bg-red-500/5 border border-red-100 dark:border-red-500/10 rounded-lg p-3">
<p className="text-xs text-red-500/80">
Você está prestes a cancelar o boleto <strong>{boleto?.numero}</strong> de <strong>{boleto?.pagador || boleto?.cliente}</strong> no valor de <strong>{boleto?.valorNominal ? parseFloat(boleto?.valorNominal).toLocaleString('pt-BR', {style: 'currency', currency:'BRL'}) : 'R$ 0,00'}</strong>.
</p>
</div>
<div className="space-y-2">
<Label className="text-slate-300 text-xs font-bold uppercase">
<Label className="text-slate-500 dark:text-slate-300 text-xs font-bold uppercase">
Motivo do Cancelamento <span className="text-red-400">*</span>
</Label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full bg-slate-800 border-slate-700 text-white rounded-md p-2 text-sm min-h-[80px]"
className="w-full bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white rounded-md p-2 text-sm min-h-[80px]"
placeholder="Descreva o motivo..."
/>
</div>
@ -1199,7 +1199,7 @@ function CancelamentoDialog({ open, onOpenChange, onConfirm, loading, reason, se
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="text-slate-400 hover:text-white"
className="text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white"
disabled={loading}
>
Voltar

View File

@ -13,13 +13,20 @@ import {
Activity,
Receipt,
ArrowUpCircle,
ArrowDownCircle
ArrowDownCircle,
Percent
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card } from '@/components/ui/card';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { extratoService } from '@/services/extratoService';
import { boletosService } from '@/services/boletosService';
@ -35,6 +42,7 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
// Novos estados para dados financeiros
const [transactions, setTransactions] = useState([]);
const [invoices, setInvoices] = useState([]);
const [clientInterest, setClientInterest] = useState([]);
const [loadingData, setLoadingData] = useState(false);
const { getCategoryName, getRuleName } = useStatementRefData();
@ -63,29 +71,30 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
const loadClientFinancials = async () => {
setLoadingData(true);
try {
const clientName = (selectedClient.nome || '').toLowerCase().trim();
const clientDisplay = (selectedClient.nome_exibicao || '').toLowerCase().trim();
const clientName = selectedClient.nome || '';
const idempresa = selectedClient.idempresa;
// 1. Busca Extrato (Transações)
const allExtrato = await extratoService.fetchExtrato();
const filteredExtrato = allExtrato.filter(item => {
const beneficiario = (item.beneficiario_pagador || '').toLowerCase();
// Verifica correspondência por nome ou nome de exibição
return (clientName && beneficiario.includes(clientName)) ||
(clientDisplay && beneficiario.includes(clientDisplay));
});
setTransactions(filteredExtrato);
// 1. Busca Transações via nova rota /beneficiario_aplicado
const transactionsData = await extratoService.fetchBeneficiarioAplicado(clientName);
setTransactions(transactionsData);
// 2. Busca Boletos (Faturas)
const boletosData = await boletosService.fetchBoletos();
// 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
const boletosData = await boletosService.fetchBoletosStatus();
const allBoletos = boletosData.cobrancas || [];
const filteredBoletos = allBoletos.filter(item => {
const pagador = (item.pagador || item.cliente || '').toLowerCase();
return (clientName && pagador.includes(clientName)) ||
(clientDisplay && pagador.includes(clientDisplay));
const nameLower = clientName.toLowerCase();
return pagador.includes(nameLower);
});
setInvoices(filteredBoletos);
// 3. Busca Juros Detalhe via nova rota /financeiro/cliente/boletos
if (idempresa) {
const interestData = await boletosService.fetchJurosCliente(idempresa);
setClientInterest(interestData);
}
} catch (error) {
console.error("Erro ao carregar dados financeiros do cliente", error);
} finally {
@ -234,7 +243,20 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
</div>
</div>
{/* Faturas Recentes */}
<Tabs defaultValue="faturas" className="w-full">
<TabsList className="w-full justify-start bg-slate-50/50 border border-slate-100 p-1 h-12 rounded-2xl mb-6">
<TabsTrigger value="faturas" className="flex-1 rounded-xl data-[state=active]:bg-white data-[state=active]:text-emerald-600 data-[state=active]:shadow-sm font-bold text-[10px] uppercase tracking-wider">
<CreditCard className="w-3.5 h-3.5 mr-2" /> Faturas
</TabsTrigger>
<TabsTrigger value="transacoes" className="flex-1 rounded-xl data-[state=active]:bg-white data-[state=active]:text-blue-600 data-[state=active]:shadow-sm font-bold text-[10px] uppercase tracking-wider">
<Receipt className="w-3.5 h-3.5 mr-2" /> Transações
</TabsTrigger>
<TabsTrigger value="juros" className="flex-1 rounded-xl data-[state=active]:bg-white data-[state=active]:text-amber-600 data-[state=active]:shadow-sm font-bold text-[10px] uppercase tracking-wider">
<Percent className="w-3.5 h-3.5 mr-2" /> Juros
</TabsTrigger>
</TabsList>
<TabsContent value="faturas">
<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">
<h4 className="font-bold text-slate-800 text-[10px] uppercase tracking-[0.2em] flex items-center gap-3">
@ -243,13 +265,13 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
</div>
Faturas & Boletos
</h4>
<Button size="sm" variant="ghost" className="h-8 text-[10px] font-bold uppercase text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 tracking-widest">
{loadingData ? 'Carregando...' : `${invoices.length} faturas`}
<Button size="sm" variant="ghost" className="h-8 text-[10px] font-bold uppercase text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 tracking-widest leading-none">
{loadingData ? '...' : `${invoices.length} faturas`}
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto p-2">
<div className="max-h-[350px] overflow-y-auto p-2">
{invoices.length === 0 ? (
<div className="text-center py-8 text-slate-400 text-xs font-medium">
<div className="text-center py-12 text-slate-400 text-xs font-medium">
Nenhuma fatura encontrada.
</div>
) : (
@ -284,8 +306,9 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
)}
</div>
</Card>
</TabsContent>
{/* Extrato / Transações */}
<TabsContent value="transacoes">
<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">
<h4 className="font-bold text-slate-800 text-[10px] uppercase tracking-[0.2em] flex items-center gap-3">
@ -294,13 +317,13 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
</div>
Extrato & Transações
</h4>
<Button size="sm" variant="ghost" className="h-8 text-[10px] font-bold uppercase text-blue-600 hover:text-blue-700 hover:bg-blue-50 tracking-widest">
{loadingData ? 'Carregando...' : `${transactions.length} registros`}
<Button size="sm" variant="ghost" className="h-8 text-[10px] font-bold uppercase text-blue-600 hover:text-blue-700 hover:bg-blue-50 tracking-widest leading-none">
{loadingData ? '...' : `${transactions.length} registros`}
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto p-2">
<div className="max-h-[350px] overflow-y-auto p-2">
{transactions.length === 0 ? (
<div className="text-center py-8 text-slate-400 text-xs font-medium">
<div className="text-center py-12 text-slate-400 text-xs font-medium">
Nenhuma transação encontrada no extrato.
</div>
) : (
@ -319,6 +342,53 @@ export const ClientPanel = ({ isOpen, onClose, clients = [] }) => {
)}
</div>
</Card>
</TabsContent>
<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">
<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>
<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>
</div>
<div className="max-h-[350px] overflow-y-auto p-2">
{clientInterest.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-xs font-medium">
Nenhum detalhe de juros encontrado.
</div>
) : (
clientInterest.map((item, i) => (
<div key={i} className="flex justify-between items-center p-4 hover:bg-slate-50 rounded-2xl transition-colors group">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center group-hover:bg-white transition-colors">
<Percent className="w-5 h-5 text-amber-500" />
</div>
<div>
<p className="text-xs font-bold text-slate-900">
{item.descricao || `Juros #${item.numero || i}`}
</p>
<p className="text-[10px] text-slate-500 font-medium">
Data: {formatDate(item.data || item.data_vencimento)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-bold text-amber-600">+{formatCurrency(item.valor_juros || item.juros || 0)}</p>
<p className="text-[9px] text-slate-400 font-bold uppercase tracking-tighter">Valor Base: {formatCurrency(item.valor || 0)}</p>
</div>
</div>
))
)}
</div>
</Card>
</TabsContent>
</Tabs>
<div className="flex gap-4 pt-4">
<Button className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-900 border-none font-bold text-[10px] uppercase tracking-widest h-12 shadow-sm">

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { extratoService } from '@/services/extratoService';
import { boletosService } from '@/services/boletosService';
import {
Users,
User,
@ -28,7 +29,8 @@ import {
ArrowUpCircle,
ArrowDownCircle,
AlertCircle,
Wrench
Wrench,
Percent
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@ -114,6 +116,9 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
const [clientExtrato, setClientExtrato] = useState([]);
const [loadingExtrato, setLoadingExtrato] = useState(false);
const [clientInterest, setClientInterest] = useState([]);
const [loadingInterest, setLoadingInterest] = useState(false);
const [clientFaturaUrl, setClientFaturaUrl] = useState(null);
const [loadingFatura, setLoadingFatura] = useState(false);
const [clientServicosAtribuidos, setClientServicosAtribuidos] = useState([]);
@ -244,53 +249,48 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
const fetchExtratoData = async () => {
setLoadingExtrato(true);
const clientName = (selectedClient.nome || '').toLowerCase().trim();
const clientDisplay = (selectedClient.nome_exibicao || '').toLowerCase().trim();
console.group('🔍 Buscando Extrato para Cliente');
console.log('Cliente Selecionado:', selectedClient);
console.log('Nome para filtro:', clientName);
console.log('Nome Exibição para filtro:', clientDisplay);
const clientName = selectedClient.nome || '';
try {
const allExtrato = await extratoService.fetchExtrato();
console.log('Total de transações recebidas:', allExtrato?.length);
if (Array.isArray(allExtrato)) {
const filteredExtrato = allExtrato.filter(item => {
const beneficiario = (item.beneficiario_pagador || '').toLowerCase();
const descricao = (item.descricao || '').toLowerCase();
// Verifica se o beneficiário ou descrição contém o nome do cliente
const matchName = clientName && (beneficiario.includes(clientName) || descricao.includes(clientName));
const matchDisplay = clientDisplay && (beneficiario.includes(clientDisplay) || descricao.includes(clientDisplay));
// Log para debugging de correspondência (apenas para os primeiros 5 ou se der match)
// if (matchName || matchDisplay) {
// console.log(' Match encontrado:', { beneficiario, descricao, matchName, matchDisplay });
// }
return matchName || matchDisplay;
});
console.log('Transações filtradas:', filteredExtrato.length);
console.table(filteredExtrato.slice(0, 5)); // Mostra as primeiras 5
setClientExtrato(filteredExtrato);
} else {
console.warn('⚠️ Resposta do extrato não é um array:', allExtrato);
setClientExtrato([]);
}
// Agora usa a rota otimizada /beneficiario_aplicado
const data = await extratoService.fetchBeneficiarioAplicado(clientName);
setClientExtrato(data);
} catch (error) {
console.error('❌ Erro ao buscar extrato:', error);
setClientExtrato([]);
} finally {
setLoadingExtrato(false);
console.groupEnd();
}
};
fetchExtratoData();
}, [clientTab, selectedClient?.idempresa ?? selectedClient?.id, selectedClient?.nome]);
// Carrega detalhes de juros quando abrir a aba Juros
useEffect(() => {
if (clientTab !== 'juros' || !selectedClient) {
setClientInterest([]);
return;
}
const fetchInterestData = async () => {
setLoadingInterest(true);
const idempresa = selectedClient.idempresa || selectedClient.id;
try {
if (idempresa) {
const data = await boletosService.fetchJurosCliente(idempresa);
setClientInterest(data);
}
} catch (error) {
console.error('❌ Erro ao buscar juros:', error);
setClientInterest([]);
} finally {
setLoadingInterest(false);
}
};
fetchInterestData();
}, [clientTab, selectedClient?.idempresa ?? selectedClient?.id]);
// Carrega caixinhas ao abrir o Dialog de cliente (mesma rota da conciliação: GET /caixinhas/apresentar)
@ -952,6 +952,9 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
<TabsTrigger value="faturas" className="text-xs sm:text-sm font-medium data-[state=active]:border-b-2 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 dark:data-[state=active]:text-blue-400 rounded-none pb-3 text-slate-600 dark:text-slate-400 whitespace-nowrap">
Faturas
</TabsTrigger>
<TabsTrigger value="juros" className="text-xs sm:text-sm font-medium data-[state=active]:border-b-2 data-[state=active]:border-amber-600 data-[state=active]:text-amber-600 dark:data-[state=active]:text-amber-400 rounded-none pb-3 text-slate-600 dark:text-slate-400 whitespace-nowrap">
Juros
</TabsTrigger>
<TabsTrigger value="servicos-produtos" className="text-xs sm:text-sm font-medium data-[state=active]:border-b-2 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 dark:data-[state=active]:text-blue-400 rounded-none pb-3 text-slate-600 dark:text-slate-400 whitespace-nowrap">
Serviços/Produtos
</TabsTrigger>
@ -1175,15 +1178,14 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
{loadingExtrato ? (
<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" />
<span className="text-sm">Analisando transações...</span>
<span className="text-sm">Buscando transações via /beneficiario_aplicado...</span>
</div>
) : clientExtrato.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-slate-400" />
<p className="text-sm font-medium">Nenhuma transação encontrada</p>
<p className="text-xs mt-2 max-w-[300px] mx-auto opacity-70">
Verificamos o extrato buscando por "{selectedClient.nome}" ou "{selectedClient.nome_exibicao || ''}".
<br/>Verifique os logs (F12) para detalhes.
Consultamos o beneficiário "{selectedClient.nome}" e não retornou registros.
</p>
</div>
) : (
@ -1394,6 +1396,79 @@ export const ClientsView = ({ clients: initialClients = [] }) => {
</div>
</TabsContent>
<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">
<h4 className="text-base sm:text-lg font-bold text-slate-900 dark:text-white">Detalhamento de Juros</h4>
<Badge variant="outline" className="text-xs bg-amber-500/10 text-amber-600 border-amber-500/20">
{clientInterest.length} registros
</Badge>
</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" />
<span className="text-sm">Buscando juros via /financeiro/cliente/boletos...</span>
</div>
) : clientInterest.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<Percent className="w-12 h-12 mx-auto mb-4 text-slate-400" />
<p className="text-sm font-medium">Nenhum detalhe de juros encontrado</p>
</div>
) : (
<div className="h-[500px]">
<ExcelTable
data={clientInterest}
columns={[
{
field: 'data',
header: 'Data',
width: '120px',
render: (row) => (
<span className="text-slate-600 dark:text-slate-400">
{formatDate(row.data || row.data_vencimento)}
</span>
)
},
{
field: 'descricao',
header: 'Descrição',
width: '300px',
render: (row) => (
<span className="font-medium text-slate-900 dark:text-white truncate">
{row.descricao || `Juros #${row.numero || ''}`}
</span>
)
},
{
field: 'valor_juros',
header: 'Valor Juros',
width: '150px',
render: (row) => (
<span className="font-bold text-amber-600">
+{formatCurrency(row.valor_juros || row.juros || 0)}
</span>
)
},
{
field: 'valor',
header: 'Valor Base',
width: '150px',
render: (row) => (
<span className="text-slate-500 text-sm">
{formatCurrency(row.valor || 0)}
</span>
)
}
]}
rowKey={(row, i) => i}
pageSize={15}
/>
</div>
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>

View File

@ -119,7 +119,7 @@ export const ContasReceberView = ({ initialView }) => {
<div className="flex items-center justify-between gap-4 mb-4 sm:mb-6">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white tracking-tight flex items-center gap-2 sm:gap-3">
Recebíveis
<Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20 text-[10px] hidden sm:inline-flex">PREMIUM v2</Badge>
{/* <Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20 text-[10px] hidden sm:inline-flex">PREMIUM v2</Badge> */}
</h1>
{/* <Button

View File

@ -199,26 +199,57 @@ export const CruzamentoView = ({ data = [], kpis = {}, caixas = [], entradasPlan
fetchTotais();
}, [filtroMes, filtroAno, filtroTipo]);
const [chartDataBackend, setChartDataBackend] = React.useState([]);
const [isLoadingChart, setIsLoadingChart] = React.useState(false);
// Efeito para buscar dados do gráfico do backend
React.useEffect(() => {
const fetchChartData = async () => {
setIsLoadingChart(true);
try {
const params = {
ano: filtroAno
};
if (filtroTipo === 'mes') {
params.mes = filtroMes;
}
// Nota: o sistema atual não parece ter seletor de 'dia' global aqui,
// mas a rota suporta. Se houver dia no futuro, adicione aqui.
const result = await extratoService.fetchPlanejadoGrafico(params);
const data = result?.grafico ?? result ?? [];
setChartDataBackend(Array.isArray(data) ? data : []);
} catch (err) {
console.error('[CruzamentoView] Erro ao buscar dados do gráfico:', err);
setChartDataBackend([]);
} finally {
setIsLoadingChart(false);
}
};
fetchChartData();
}, [filtroMes, filtroAno, filtroTipo]);
// KPIs recalculados com dados filtrados (ou vindos do backend se for mensal)
const kpisFiltrados = React.useMemo(() => {
if (filtroTipo === 'mes' && totaisPlanejados) {
return {
totalRecebido: totaisPlanejados.total_realizado || 0,
totalPendente: totaisPlanejados.total_boletos_a_receber || 0,
totalGeral: totaisPlanejados.total_planejado_geral || 0
total_realizado: totaisPlanejados.total_realizado || 0,
total_boletos_a_receber: totaisPlanejados.total_boletos_a_receber || 0,
total_planejado_geral: totaisPlanejados.total_planejado_geral || 0
};
}
const totalRecebido = dadosFiltrados
const total_realizado = dadosFiltrados
.filter(t => t?.status === 'Recebido' || t?.status === 'Liquidado')
.reduce((acc, t) => acc + (t?.valor || 0), 0);
const totalPendente = planejadasFiltradas.reduce((acc, t) => acc + (Number(t?.total || 0)), 0);
const total_boletos_a_receber = planejadasFiltradas.reduce((acc, t) => acc + (Number(t?.total || 0)), 0);
return {
totalRecebido,
totalPendente,
totalGeral: totalRecebido + totalPendente
total_realizado,
total_boletos_a_receber,
total_planejado_geral: total_realizado + total_boletos_a_receber
};
}, [dadosFiltrados, planejadasFiltradas, filtroTipo, totaisPlanejados]);
@ -290,6 +321,30 @@ export const CruzamentoView = ({ data = [], kpis = {}, caixas = [], entradasPlan
// Dados comparativos: planejado vs executado
const comparativoData = useMemo(() => {
if (chartDataBackend.length > 0) {
return chartDataBackend.map(item => {
const variacao = (item.executado || 0) - (item.planejado || 0);
const percentual = (item.planejado || 0) > 0 ? (((item.executado || 0) / item.planejado) * 100).toFixed(1) : 0;
// Formata o nome do período para exibição
let name = String(item.periodo);
if (filtroTipo === 'ano') {
const mesesLabel = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const mesIdx = parseInt(item.periodo) - 1;
if (mesIdx >= 0 && mesIdx < 12) name = mesesLabel[mesIdx];
} else if (filtroTipo === 'mes') {
name = `${String(item.periodo).padStart(2, '0')}/${String(filtroMes).padStart(2, '0')}`;
}
return {
...item,
name,
variacao,
percentual
};
});
}
const mesesLabel = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const map = {};
@ -338,9 +393,9 @@ export const CruzamentoView = ({ data = [], kpis = {}, caixas = [], entradasPlan
.map(([name, values]) => {
const variacao = values.executado - values.planejado;
const percentual = values.planejado > 0 ? ((values.executado / values.planejado) * 100).toFixed(1) : 0;
return { ...values, variacao, percentual };
return { ...values, name, variacao, percentual };
});
}, [dadosFiltrados, planejadasFiltradas, filtroTipo]);
}, [dadosFiltrados, planejadasFiltradas, filtroTipo, chartDataBackend, filtroMes]);
// Análise de Variância baseada exclusivamente na rota de soma por categoria
const variacaoCategoria = useMemo(() => {
@ -367,30 +422,36 @@ export const CruzamentoView = ({ data = [], kpis = {}, caixas = [], entradasPlan
// KPIs de variância: Usando os dados reais
const kpisVariacao = useMemo(() => {
if (filtroTipo === 'mes' && totaisPlanejados) {
const totalExecutado = totaisPlanejados.total_realizado || 0;
const totalPlanejado = totaisPlanejados.total_planejado || 0;
const variacaoTotal = totaisPlanejados.diferenca || 0;
const percentualVariacao = totalPlanejado > 0 ? ((totalExecutado / totalPlanejado) * 100).toFixed(1) : '0';
return {
totalExecutado,
totalPlanejado,
variacaoTotal,
percentualVariacao
total_realizado: totaisPlanejados.total_realizado || 0,
total_planejado: totaisPlanejados.total_planejado || 0,
total_boletos_a_receber: totaisPlanejados.total_boletos_a_receber || 0,
diferenca: totaisPlanejados.diferenca || 0,
percentual_variacao: (totaisPlanejados.total_planejado || 0) > 0
? ((totaisPlanejados.total_realizado / totaisPlanejados.total_planejado) * 100).toFixed(1)
: '0'
};
}
const totalExecutado = kpisFiltrados.totalRecebido;
const totalPlanejado = kpisFiltrados.totalPendente;
const variacaoTotal = totalExecutado - totalPlanejado;
const percentualVariacao = totalPlanejado > 0 ? ((totalExecutado / totalPlanejado) * 100).toFixed(1) : '0';
const total_realizado = kpisFiltrados.total_realizado;
const total_planejado = planejadasFiltradas
.filter(p => !p.parcelado) // Exemplo de lógica local se necessário
.reduce((acc, p) => acc + (Number(p.total || 0)), 0);
const total_boletos_a_receber = kpisFiltrados.total_boletos_a_receber;
const diferenca = total_realizado - (total_planejado + total_boletos_a_receber);
const percentual_variacao = (total_planejado + total_boletos_a_receber) > 0
? ((total_realizado / (total_planejado + total_boletos_a_receber)) * 100).toFixed(1)
: '0';
return {
totalExecutado,
totalPlanejado,
variacaoTotal,
percentualVariacao
total_realizado,
total_planejado,
total_boletos_a_receber,
diferenca,
percentual_variacao
};
}, [kpisFiltrados, filtroTipo, totaisPlanejados]);
}, [kpisFiltrados, filtroTipo, totaisPlanejados, planejadasFiltradas]);
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#f43f5e'];
@ -478,27 +539,27 @@ export const CruzamentoView = ({ data = [], kpis = {}, caixas = [], entradasPlan
</div>
</div>
{/* KPIs: TODOS ZERADOS exceto Total Executado */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 md:gap-6 relative z-10">
{/* KPIs Principais */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-3 sm:gap-4 md:gap-6 relative z-10">
<KPICard
title="Liquidado"
value={formatCurrency(kpisFiltrados.totalRecebido)}
value={formatCurrency(kpisFiltrados.total_realizado)}
subtext="Total recebido efetivamente"
icon={CheckCircle2}
colorClass="text-emerald-500"
loading={isLoadingTotais}
/>
<KPICard
{/* <KPICard
title="Expectativa"
value={formatCurrency(kpisFiltrados.totalPendente)}
value={formatCurrency(kpisFiltrados.total_planejado)}
subtext="Total planejado + boletos á receber p/ o período"
icon={Clock}
colorClass="text-blue-500"
loading={isLoadingTotais}
/>
/> */}
<KPICard
title="Volume Geral"
value={formatCurrency(kpisFiltrados.totalGeral)}
value={formatCurrency(kpisFiltrados.total_planejado_geral)}
subtext="Soma de realizado + esperado"
icon={TrendingUp}
colorClass="text-purple-500"
@ -506,20 +567,20 @@ export const CruzamentoView = ({ data = [], kpis = {}, caixas = [], entradasPlan
/>
</div>
{/* KPIs de Variância: APENAS Total Executado alimentado */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 relative z-10">
{/* KPIs de Variância */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 md:gap-6 relative z-10">
<KPICard
title="Expectativa (Plan)"
value={formatCurrency(kpisVariacao.totalPlanejado)}
value={formatCurrency(kpisVariacao.total_planejado)}
subtext="Total de entradas planejadas"
icon={Target}
colorClass="text-blue-500"
loading={isLoadingTotais}
/>
<KPICard
title="Realizado (Exec)"
value={formatCurrency(kpisVariacao.totalExecutado)}
subtext="Realizado efetivamente (extrato)"
title="Boletos á receber"
value={formatCurrency(kpisVariacao.total_boletos_a_receber)}
subtext="Boletos á receber no perido"
icon={CheckCircle2}
colorClass="text-emerald-500"
highlight
@ -527,21 +588,21 @@ export const CruzamentoView = ({ data = [], kpis = {}, caixas = [], entradasPlan
/>
<KPICard
title="Variação"
value={formatCurrency(kpisVariacao.variacaoTotal)}
subtext={kpisVariacao.variacaoTotal >= 0 ? "Acima do planejado" : "Abaixo do planejado"}
value={formatCurrency(kpisVariacao.diferenca)}
subtext={kpisVariacao.diferenca >= 0 ? "Acima do planejado" : "Abaixo do planejado"}
icon={BarChart3}
trend={kpisVariacao.variacaoTotal >= 0 ? 'up' : 'down'}
colorClass={kpisVariacao.variacaoTotal >= 0 ? "text-emerald-500" : "text-rose-500"}
trend={kpisVariacao.diferenca >= 0 ? 'up' : 'down'}
colorClass={kpisVariacao.diferenca >= 0 ? "text-emerald-500" : "text-rose-500"}
loading={isLoadingTotais}
/>
<KPICard
{/* <KPICard
title="Taxa de Entrega"
value={`${kpisVariacao.percentualVariacao}%`}
subtext="Realizado vs Planejado"
icon={Activity}
colorClass="text-purple-500"
loading={isLoadingTotais}
/>
/> */}
</div>
{/* Gráfico Comparativo Planejado vs Executado */}

View File

@ -39,6 +39,7 @@ import { useToast } from '../../hooks/useToast';
import { cn } from '@/lib/utils';
import ExcelTable from '../../components/ExcelTable';
import { formatDate, formatCurrency } from '../../utils/dateUtils';
import { CurrencyInputV2 } from '../../components/CurrencyInputV2';
export const EntradasPlanejadasView = () => {
const { state, actions } = useContasReceber();
@ -77,6 +78,7 @@ export const EntradasPlanejadasView = () => {
// Estados para o campo inteligente de serviços
const [servicos, setServicos] = useState([]);
const [catalogType, setCatalogType] = useState('servico'); // 'servico' | 'produto'
const [searchTermServico, setSearchTermServico] = useState('');
const [showServicoSuggestions, setShowServicoSuggestions] = useState(false);
const [activeItemIndex, setActiveItemIndex] = useState(null);
@ -123,7 +125,7 @@ export const EntradasPlanejadasView = () => {
// Carrega a lista de serviços para o campo inteligente
const fetchServicos = async () => {
try {
const data = await actions?.loadServicos?.();
const data = await actions?.loadServicos?.(catalogType);
setServicos(Array.isArray(data) ? data : []);
} catch (err) {
console.error('[EntradasPlanejadasView] Erro ao carregar serviços:', err);
@ -131,7 +133,7 @@ export const EntradasPlanejadasView = () => {
}
};
fetchServicos();
}, [actions.loadEntradasPlanejadas, actions.loadServicos]);
}, [actions.loadEntradasPlanejadas, actions.loadServicos, catalogType]);
const handleOpenDialog = async (entrada = null) => {
if (entrada) {
@ -892,10 +894,38 @@ export const EntradasPlanejadasView = () => {
{/* Seletor de Serviços (Movido para fora da lista) */}
<div className="relative mb-4 bg-blue-50/50 dark:bg-blue-500/5 p-4 rounded-xl border border-blue-100 dark:border-blue-500/10">
<Label className="text-blue-600 dark:text-blue-400 text-xs font-bold uppercase flex items-center gap-2 mb-2">
<div className="flex items-center justify-between mb-2">
<Label className="text-blue-600 dark:text-blue-400 text-xs font-bold uppercase flex items-center gap-2">
<Search className="w-3 h-3" />
Adicionar Item do Catálogo
</Label>
<div className="flex bg-slate-100 dark:bg-slate-800 p-0.5 rounded-lg border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setCatalogType('servico')}
className={cn(
"px-3 py-1 text-[10px] font-bold rounded-md transition-all",
catalogType === 'servico'
? "bg-white dark:bg-slate-700 text-blue-600 dark:text-blue-400 shadow-sm"
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
)}
>
SERVIÇO
</button>
<button
type="button"
onClick={() => setCatalogType('produto')}
className={cn(
"px-3 py-1 text-[10px] font-bold rounded-md transition-all",
catalogType === 'produto'
? "bg-white dark:bg-slate-700 text-blue-600 dark:text-blue-400 shadow-sm"
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
)}
>
PRODUTO
</button>
</div>
</div>
<div className="relative">
<Input
placeholder="Digite para buscar um serviço ou produto..."
@ -980,11 +1010,10 @@ export const EntradasPlanejadasView = () => {
/>
</TableCell>
<TableCell className="py-2">
<Input
type="number"
<CurrencyInputV2
className="h-9 text-right border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
value={item.preco}
onChange={(e) => handleItemChange(index, 'preco', e.target.value)}
onChange={(val) => handleItemChange(index, 'preco', val || 0)}
/>
</TableCell>
<TableCell className="py-2 text-right">
@ -1033,11 +1062,10 @@ export const EntradasPlanejadasView = () => {
<span className="text-slate-500 font-medium">Desconto:</span>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-medium font-bold">-</span>
<Input
type="number"
<CurrencyInputV2
className="h-8 w-24 text-right font-bold text-red-500 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
value={formData.desconto}
onChange={(e) => setFormData({...formData, desconto: e.target.value})}
onChange={(val) => setFormData({...formData, desconto: val || 0})}
/>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema } from './schema';
import { submitDriverRegistration } from '../../services/driverService';
import { updateDriverRegistration, submitInternalDriverRegistration, getBases } from '../../services/grService';
import { handleGrError } from '../../utils/grErrorHandler';
import { useGrStore } from '../../services/useGrStore';
import { toast } from 'sonner';
import { motion, AnimatePresence } from 'framer-motion';
@ -30,6 +31,8 @@ const VEHICLE_TYPES = [
{ value: 'van', label: 'Van' },
{ value: 'vuc', label: 'VUC (Veículo Urbano de Carga)' },
{ value: 'caminhao', label: 'Caminhão' },
{ value: 'cavalo', label: 'Cavalo' },
{ value: 'carreta', label: 'Carreta' },
{ value: 'moto', label: 'Moto' },
];
@ -69,7 +72,7 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
const [submitted, setSubmitted] = useState(false);
const [filePreviews, setFilePreviews] = useState({});
const { register, handleSubmit, watch, setValue, trigger, control, formState: { errors }, reset } = useForm({
const { register, handleSubmit, watch, setValue, trigger, control, setError, formState: { errors }, reset } = useForm({
resolver: zodResolver(registrationSchema),
defaultValues: {
forma_trab: '',
@ -79,7 +82,8 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
terms: false,
coordenador: '',
modalidade: '',
base: ''
base: '',
is_internal: isInternal
}
});
@ -219,7 +223,7 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
if (!currentUser?.base) return basesList || [];
const userAllowedBases = currentUser.base.split(',').map(b => b.trim());
const filtered = basesList.filter(b => userAllowedBases.includes(b.base || b.nome));
const filtered = basesList.filter(b => userAllowedBases.includes((b.base || b.nome)?.trim()));
console.log('GR [FILTER]: Bases filtradas:', filtered);
return filtered;
}, [basesList, currentUser]);
@ -272,11 +276,6 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
// Validate common fields first
fieldsToValidate = ['forma_trabalho', 'nome_completo', 'data_nascimento', 'cpf', 'telefone', 'cnh', 'validade_cnh', 'email'];
// Add internal-only mandatory fields
if (isInternal) {
fieldsToValidate.push('coordenador', 'modalidade');
}
if (watch('forma_trabalho') === 'proprio') {
fieldsToValidate.push('tipo_veiculo', 'placa_veiculo');
}
@ -284,7 +283,6 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
const requiredFiles = ['cnh_verso', 'cnh_frente', 'comprovante_residencia', 'cartao_cnpj', 'crlv'];
if (watch('is_third_party_vehicle')) requiredFiles.push('owner_cnh_file');
// Valida se o arquivo foi anexado OU se já existe um preview (para casos de edição)
const missingFiles = requiredFiles.filter(field => {
const hasFile = watch(field) && watch(field).length > 0;
const hasPreview = !!filePreviews[field];
@ -300,11 +298,36 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
}
const isValid = await trigger(fieldsToValidate);
// Validação manual dos campos internos obrigatórios (superRefine não roda no trigger parcial)
if (isInternal && step === 1) {
let hasInternalErrors = false;
if (!watch('coordenador') || watch('coordenador').trim() === '') {
setError('coordenador', { type: 'manual', message: 'Nome do Coordenador é obrigatório' });
hasInternalErrors = true;
}
if (!watch('modalidade') || watch('modalidade').trim() === '') {
setError('modalidade', { type: 'manual', message: 'Modalidade é obrigatória' });
hasInternalErrors = true;
}
if (!watch('base') || watch('base').trim() === '') {
setError('base', { type: 'manual', message: 'Base de Operação é obrigatória' });
hasInternalErrors = true;
}
if (hasInternalErrors) {
toast.error("Por favor, preencha todos os campos obrigatórios.", {
description: "Verifique os avisos em vermelho no formulário."
});
return;
}
}
if (isValid || step === 2) {
setStep(prev => prev + 1);
window.scrollTo(0, 0);
} else {
// Feedback de campos faltando para o usuário
const missingFields = fieldsToValidate.filter(field => !!errors[field]);
if (missingFields.length > 0) {
toast.error("Por favor, preencha todos os campos obrigatórios.", {
@ -314,6 +337,7 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
}
};
// Funções de máscara
const maskCPF = (value) => value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})$/, '$1-$2').slice(0, 14);
const maskPhone = (value) => {
@ -460,15 +484,8 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
}
} catch (error) {
console.error("Erro CRÍTICO na submissão:", error);
toast.error("Não foi possível concluir a ação", {
description: "Ocorreu um erro inesperado. Por favor, tente novamente ou entre em contato com o suporte técnico.",
action: {
label: "Tentar Novamente",
onClick: () => onSubmit(data),
},
duration: 5000
});
// Uso do handler centralizado para garantir consistência e redirecionamento
handleGrError(error, "Não foi possível concluir o cadastro.");
} finally {
setIsSubmitting(false);
}
@ -715,7 +732,7 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
</div>
{isInternal && (
<div className="space-y-3">
<Label className="text-xs font-bold uppercase tracking-widest text-slate-400">Modalidade</Label>
<Label className="text-xs font-bold uppercase tracking-widest text-slate-400">Modalidade <span className="text-red-500">*</span></Label>
<Select onValueChange={(val) => setValue('modalidade', val)} value={watch('modalidade')}>
<SelectTrigger className={cn("h-14 rounded-2xl bg-slate-100 dark:bg-black/20 border-slate-200 dark:border-white/10 shadow-sm", errors.modalidade && "border-red-500")}>
<SelectValue placeholder="Selecione..." />
@ -731,12 +748,12 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
{isInternal && (
<>
<div className="md:col-span-2 space-y-3">
<Label className="text-xs font-bold uppercase tracking-widest text-slate-400">Nome Coordenador</Label>
<Label className="text-xs font-bold uppercase tracking-widest text-slate-400">Nome Coordenador <span className="text-red-500">*</span></Label>
<Input {...register('coordenador')} className={cn("h-14 rounded-2xl bg-slate-100 dark:bg-black/20 shadow-sm", errors.coordenador && "border-red-500")} />
{errors.coordenador && <span className="text-[10px] text-red-500 font-bold uppercase">{errors.coordenador.message}</span>}
</div>
<div className="md:col-span-2 space-y-3">
<Label className="text-xs font-bold uppercase tracking-widest text-slate-400">Base de Operação</Label>
<Label className="text-xs font-bold uppercase tracking-widest text-slate-400">Base de Operação <span className="text-red-500">*</span></Label>
<Controller
name="base"
control={control}
@ -745,18 +762,13 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
<Select
onValueChange={(val) => {
console.log('GR [SELECT]: Base selecionada:', val);
console.log('GR [SELECT]: Valor atual do field:', field.value);
// Only update if value is not empty and different from current
if (val && val !== field.value) {
field.onChange(val);
console.log('GR [SELECT]: field.onChange chamado. Valor:', val);
} else {
console.log('GR [SELECT]: onChange ignorado (valor vazio ou igual)');
}
}}
value={field.value || ""}
>
<SelectTrigger className="h-14 rounded-2xl bg-slate-100 dark:bg-black/20 border-slate-200 dark:border-white/10 shadow-sm">
<SelectTrigger className={cn("h-14 rounded-2xl bg-slate-100 dark:bg-black/20 border-slate-200 dark:border-white/10 shadow-sm", errors.base && "border-red-500")}>
<SelectValue placeholder="Selecione a base..." />
</SelectTrigger>
<SelectContent className="rounded-2xl border-slate-200 dark:border-white/10 bg-white dark:bg-[#151515] shadow-2xl max-h-[200px]">
@ -772,6 +784,7 @@ export const DriverRegistrationForm = ({ onSuccess, isInternal = false }) => {
</Select>
)}
/>
{errors.base && <span className="text-[10px] text-red-500 font-bold uppercase">{errors.base.message}</span>}
</div>
</>
)}

View File

@ -24,6 +24,7 @@ export const registrationSchema = z.object({
modalidade: z.string().optional(),
email: z.string().email("Email inválido"),
base: z.string().optional(),
is_internal: z.boolean().optional(),
// Etapa 3
cnpj: z.string().optional(),
@ -49,6 +50,31 @@ export const registrationSchema = z.object({
is_third_party_vehicle: z.boolean().optional(),
terms: z.literal(true, { errorMap: () => ({ message: "Você deve aceitar os termos" }) }),
}).superRefine((data, ctx) => {
// Validação restrita para cadastros internos
if (data.is_internal) {
if (!data.coordenador || data.coordenador.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Nome do Coordenador é obrigatório",
path: ['coordenador'],
});
}
if (!data.modalidade || data.modalidade.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Modalidade é obrigatória",
path: ['modalidade'],
});
}
if (!data.base || data.base.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base de Operação é obrigatória",
path: ['base'],
});
}
}
if (data.forma_trabalho === 'proprio') {
if (!data.tipo_veiculo) {
ctx.addIssue({

View File

@ -103,8 +103,8 @@ export const GrKanbanBoard = ({
{/* Cards Area */}
<div className="flex-1 px-3 py-4 overflow-y-auto custom-scrollbar min-h-0">
<div className="flex flex-col gap-4 pb-2">
{safeCards.map((card) => (
<div key={card.id} className="group relative">
{safeCards.map((card, idx) => (
<div key={`${card.id}-${idx}`} className="group relative">
<GrKanbanCard
item={card}
title={card.title}

View File

@ -187,7 +187,7 @@ export const GrKanbanCard = ({
<div className="flex items-center gap-1.5 shrink-0">
<Calendar size={10} className="mb-0.5" />
<span className="whitespace-nowrap">
{date && date.includes('00:00:00') ? date.split(' ')[0] : date}
{typeof date === 'string' && date.includes('00:00:00') ? date.split(' ')[0] : date}
</span>
</div>

View File

@ -86,10 +86,10 @@ export const GrRegistrationDetails = ({
return bases;
}
if (!currentUser?.base) return [];
if (!currentUser?.base) return bases || [];
const userAllowedBases = currentUser.base.split(',').map(b => b.trim());
return bases.filter(b => userAllowedBases.includes(b.base || b.nome));
return bases.filter(b => userAllowedBases.includes((b.base || b.nome)?.trim()));
}, [bases, currentUser]);
// Determine available documents based on real API fields
@ -475,6 +475,8 @@ export const GrRegistrationDetails = ({
<option value="carro_passeio" className="bg-white dark:bg-[#1a1a1a]">Carro de Passeio</option>
<option value="utilitario" className="bg-white dark:bg-[#1a1a1a]">Utilitário</option>
<option value="caminhao" className="bg-white dark:bg-[#1a1a1a]">Caminhão</option>
<option value="cavalo" className="bg-white dark:bg-[#1a1a1a]">Cavalo</option>
<option value="carreta" className="bg-white dark:bg-[#1a1a1a]">Carreta</option>
<option value="moto" className="bg-white dark:bg-[#1a1a1a]">Moto</option>
</select>
</div>

View File

@ -1,7 +1,10 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { Search, Plus, X } from 'lucide-react';
import { toast } from 'sonner';
import { handleGrError } from '../utils/grErrorHandler';
import ExcelTable from '../components/ExcelTable';
import { GrRegistrationDetails } from '../components/GrRegistrationDetails';
import { useGrStore } from '../services/useGrStore';
import * as grService from '../services/grService';
@ -38,8 +41,7 @@ const GrRegistrationsSimplified = () => {
const driverData = response.Base_Dados_API || response.dados || response;
setData(Array.isArray(driverData) ? driverData : (driverData.dados || []));
} catch (error) {
console.error("Error fetching GR data:", error);
toast.error("Erro ao carregar cadastros");
handleGrError(error, "Erro ao carregar cadastros");
} finally {
setLoading(false);
}
@ -155,8 +157,8 @@ const GrRegistrationsSimplified = () => {
toast.success('Cadastro arquivado com sucesso');
fetchData();
}).catch(() => {
toast.error('Erro ao excluir cadastro');
}).catch((error) => {
handleGrError(error, 'Erro ao excluir cadastro');
});
}
}}
@ -199,11 +201,7 @@ const GrRegistrationsSimplified = () => {
fetchData();
setSelectedCard(null);
} catch (error) {
console.error("Error saving GR registration:", error);
toast.error("Não foi possível salvar as alterações", {
description: "Verifique sua conexão e tente novamente. Se o problema persistir, contate o suporte.",
duration: 5000
});
handleGrError(error, "Não foi possível salvar as alterações");
}
}}
onProgressStatus={() => {}}

View File

@ -33,7 +33,7 @@ export const GrUserForm = ({
{/* 1. Nome Completo */}
<div className="space-y-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Nome</Label>
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Nome <span className="text-red-500">*</span></Label>
<Input
value={formData.nome_completo}
onChange={(e) => handleInputChange('nome_completo', e.target.value)}
@ -45,7 +45,7 @@ export const GrUserForm = ({
{/* 2. Email */}
<div className="space-y-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Email</Label>
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Email <span className="text-red-500">*</span></Label>
<Input
type="email"
value={formData.email}
@ -109,7 +109,7 @@ export const GrUserForm = ({
{/* 6. Base (Oculto se for Coordenador) */}
{showBaseField && (
<div className="space-y-2 animate-in slide-in-from-top-2 duration-300">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Bases (Múltipla Seleção)</Label>
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Bases (Múltipla Seleção) <span className="text-red-500">*</span></Label>
<div className="p-4 rounded-2xl bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 shadow-sm max-h-[200px] overflow-y-auto space-y-2 custom-scrollbar">
{(() => {
const masterBaseNames = new Set(bases.map(b => b.base || b.nome));
@ -158,10 +158,11 @@ export const GrUserForm = ({
{/* 7. Responsável de Base (Pode ser dependente da escolha da base no futuro, mantendo simples por enquanto) */}
<div className="space-y-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Responsável de Base</Label>
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Responsável de Base <span className="text-red-500">*</span></Label>
<Select
value={formData.responsavel_base}
onValueChange={(val) => handleInputChange('responsavel_base', val)}
required
>
<SelectTrigger className="h-12 rounded-2xl bg-white dark:bg-white/5 border-slate-200 dark:border-white/10 shadow-sm transition-all focus:ring-2 focus:ring-[var(--gr-primary)]/20">
<SelectValue placeholder="Selecione o responsável..." />
@ -176,6 +177,28 @@ export const GrUserForm = ({
</Select>
</div>
{/* 8. Modalidade - Comentado conforme solicitado por não ser usado aqui */}
{/*
<div className="space-y-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">Modalidade <span className="text-red-500">*</span></Label>
<Select
value={formData.modalidade}
onValueChange={(val) => handleInputChange('modalidade', val)}
required
>
<SelectTrigger className="h-12 rounded-2xl bg-white dark:bg-white/5 border-slate-200 dark:border-white/10 shadow-sm transition-all focus:ring-2 focus:ring-[var(--gr-primary)]/20">
<SelectValue placeholder="Selecione a modalidade..." />
</SelectTrigger>
<SelectContent className="rounded-2xl border-slate-200 dark:border-white/10">
<SelectItem value="Agregado">Agregado</SelectItem>
<SelectItem value="Rentals">Rentals</SelectItem>
<SelectItem value="Frota Fixa Diarista">Frota Fixa Diarista</SelectItem>
<SelectItem value="Frota Fixa Mensal">Frota Fixa Mensal</SelectItem>
</SelectContent>
</Select>
</div>
*/}
</form>
);
};

View File

@ -83,6 +83,8 @@ export const VehicleFormModal = ({ isOpen, onClose, onSave }) => {
<option value="TRUCK">Caminhão</option>
<option value="VAN">Van / Fiorino</option>
<option value="CAR">Carro / Passeio</option>
<option value="CAVALO">Cavalo</option>
<option value="CARRETA">Carreta</option>
</select>
<select

View File

@ -14,17 +14,7 @@ export const submitDriverRegistration = async (formData) => {
});
return response.data;
} catch (error) {
if (error.response?.data?.detail) {
// Tenta formatar erros de validação do Pydantic/FastAPI se vierem como lista
if (Array.isArray(error.response.data.detail)) {
const formattedErrors = error.response.data.detail
.map(err => `${err.loc[1]}: ${err.msg}`)
.join('\n');
throw new Error(formattedErrors);
}
throw new Error(JSON.stringify(error.response.data.detail));
}
throw new Error(error.message || 'Erro ao comunicar com o servidor.');
throw error;
}
};
@ -43,9 +33,6 @@ export const uploadDriverFiles = async (submissionId, fileFormData) => {
});
return response.data;
} catch (error) {
if (error.response?.data?.detail) {
throw new Error(JSON.stringify(error.response.data.detail));
}
throw new Error(error.message || 'Erro ao enviar documentos.');
throw error;
}
};

View File

@ -5,12 +5,8 @@ import api from '@/services/api';
* @returns {Promise<Object>}
*/
export const getGrEnvironment = async () => {
try {
const response = await api.get('/mounting_gr');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao montar ambiente GR');
}
};
/**
@ -19,12 +15,8 @@ export const getGrEnvironment = async () => {
* @returns {Promise<Object>}
*/
export const getDriversByFilter = async (params) => {
try {
const response = await api.post('/base/filtro', params);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar motoristas');
}
};
/**
@ -33,16 +25,12 @@ export const getDriversByFilter = async (params) => {
* @returns {Promise<Object>}
*/
export const updateDriverStatus = async (data) => {
try {
const response = await api.request({
url: '/cadastro/drivers/edit/status',
method: 'UPDATE',
data: data
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar status');
}
};
/**
@ -51,12 +39,8 @@ export const updateDriverStatus = async (data) => {
* @returns {Promise<Object>}
*/
export const getDriverContracts = async (base) => {
try {
const response = await api.get(`/contrato/drivers/filtro?base=${base}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar contratos');
}
};
/**
@ -65,15 +49,11 @@ export const getDriverContracts = async (base) => {
* @returns {Promise<Object>}
*/
export const getAllDriverRegistrations = async (modalidade = null) => {
try {
const url = modalidade
? `/cadastro/drivers/apresentar/modalidade/${modalidade}`
: '/cadastro/drivers/apresentar';
const response = await api.get(url);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar cadastros');
}
};
/**
@ -82,12 +62,8 @@ export const getAllDriverRegistrations = async (modalidade = null) => {
* @returns {Promise<Object>}
*/
export const getArchivedRegistrations = async () => {
try {
const response = await api.get('/cadastro/drivers/arquivados');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar cadastros arquivados');
}
};
/**
@ -96,15 +72,11 @@ export const getArchivedRegistrations = async () => {
* @returns {Promise<Object>}
*/
export const updateDriverRegistration = async (data) => {
try {
const isFormData = data instanceof FormData;
const response = await api.put('/cadastro/drivers/edit', data, {
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao salvar alterações do motorista');
}
};
/**
@ -113,15 +85,11 @@ export const updateDriverRegistration = async (data) => {
* @returns {Promise<Object>}
*/
export const getActiveContracts = async (modalidade = null) => {
try {
const url = modalidade
? `/contrato/drivers/apresentar?modalidade=${modalidade}`
: '/contrato/drivers/apresentar';
const response = await api.get(url);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar contratos');
}
};
/**
@ -130,12 +98,8 @@ export const getActiveContracts = async (modalidade = null) => {
* @returns {Promise<Object>}
*/
export const getArchivedContracts = async () => {
try {
const response = await api.get('/contrato/drivers/arquivados');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar contratos arquivados');
}
};
/**
@ -144,16 +108,12 @@ export const getArchivedContracts = async () => {
* @returns {Promise<Object>}
*/
export const updateContractGlobalStatus = async (data) => {
try {
const response = await api.request({
url: '/contrato/drivers/edit/status_global',
method: 'UPDATE',
data: data
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar status global do contrato');
}
};
/**
@ -162,12 +122,8 @@ export const updateContractGlobalStatus = async (data) => {
* @returns {Promise<Object>}
*/
export const updateContractSignatureStatus = async (data) => {
try {
const response = await api.put('/contrato/drivers/edit/status_assinatura', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar status de assinatura');
}
};
/**
@ -175,12 +131,8 @@ export const updateContractSignatureStatus = async (data) => {
* @returns {Promise<Object>}
*/
export const getUserList = async () => {
try {
const response = await api.get('/usuarios_gr');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar usuários');
}
};
/**
@ -189,12 +141,8 @@ export const getUserList = async () => {
* @returns {Promise<Object>}
*/
export const saveUser = async (data) => {
try {
const response = await api.post('/user_gr/edit', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao salvar usuário');
}
};
/**
@ -203,12 +151,8 @@ export const saveUser = async (data) => {
* @returns {Promise<Object>}
*/
export const deleteUser = async (userId) => {
try {
const response = await api.delete('/user_gr/delete', { data: { idusuarios_pralog: userId } });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao deletar usuário');
}
};
/**
* Cria um novo contrato.
@ -216,12 +160,8 @@ export const deleteUser = async (userId) => {
* @returns {Promise<Object>}
*/
export const createContract = async (data) => {
try {
const response = await api.post('/contrato/drivers', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao criar contrato');
}
};
/**
@ -231,12 +171,8 @@ export const createContract = async (data) => {
* @returns {Promise<Object>}
*/
export const updateContract = async (data) => {
try {
const response = await api.put('/contrato/drivers/edit', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar contrato');
}
};
/**
@ -246,15 +182,11 @@ export const updateContract = async (data) => {
* @returns {Promise<Object>}
*/
export const archiveContract = async (idcontrato_drivers) => {
try {
if (!idcontrato_drivers) {
throw new Error('ID do contrato (idcontrato_drivers) não informado para arquivamento.');
}
const response = await api.put(`/contrato/arquivar/${idcontrato_drivers}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao arquivar contrato');
}
};
/**
@ -263,13 +195,8 @@ export const archiveContract = async (idcontrato_drivers) => {
* @returns {Promise<Object>}
*/
export const createUser = async (data) => {
try {
const response = await api.post('/usuarios_gr/create', data);
return response.data;
} catch (error) {
// Lançar o erro original do axios para preservar error.response.data
throw error;
}
};
/**
@ -278,13 +205,9 @@ export const createUser = async (data) => {
* @returns {Promise<Object>}
*/
export const updateUser = async (data) => {
try {
// Note: User requested /contrato/drivers/edit for editing users as well
const response = await api.post('/user_gr/edit', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar usuário');
}
};
/**
@ -293,12 +216,8 @@ export const updateUser = async (data) => {
* @returns {Promise<Object>}
*/
export const getGrGroups = async () => {
try {
const response = await api.get('/grup_gr');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar grupos');
}
};
/**
@ -309,15 +228,11 @@ export const getGrGroups = async () => {
* @returns {Promise<Object>}
*/
export const archiveDriverRegistration = async (iddrivers) => {
try {
if (!iddrivers) {
throw new Error('ID do cadastro não informado para arquivamento.');
}
const response = await api.put(`/cadatro/arquivar/${iddrivers}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao arquivar cadastro');
}
};
/**
@ -327,16 +242,12 @@ export const archiveDriverRegistration = async (iddrivers) => {
* @returns {Promise<Object>}
*/
export const submitInternalDriverRegistration = async (data) => {
try {
const response = await api.post('/cadastro/drivers/interno', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao realizar cadastro interno');
}
};
/**
@ -345,16 +256,12 @@ export const submitInternalDriverRegistration = async (data) => {
* @returns {Promise<Object>}
*/
export const updateDriverDocStatus = async (data) => {
try {
const response = await api.request({
url: '/cadastro/drivers/edit/status_motorista_carro',
method: 'UPDATE',
data: data
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar status dos documentos');
}
};
/**
@ -364,12 +271,8 @@ export const updateDriverDocStatus = async (data) => {
* @returns {Promise<Object>}
*/
export const saveEmailCredentials = async (email, senha) => {
try {
const response = await api.post('/email_xml/credenciais', { email, senha });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao salvar credenciais de e-mail');
}
};
/**
@ -378,12 +281,8 @@ export const saveEmailCredentials = async (email, senha) => {
* @returns {Promise<Object>}
*/
export const processEmails = async (data) => {
try {
const response = await api.post('/email_xml/processar', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao processar e-mails');
}
};
/**
@ -392,7 +291,6 @@ export const processEmails = async (data) => {
* @returns {Promise<Object>}
*/
export const validateXML = async (file) => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/email_xml/validar', formData, {
@ -401,9 +299,6 @@ export const validateXML = async (file) => {
}
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao validar arquivo XML');
}
};
@ -414,12 +309,8 @@ export const validateXML = async (file) => {
* @returns {Promise<Object>}
*/
export const changeUserPassword = async (data) => {
try {
const response = await api.post('/user_gr/senha', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao alterar senha');
}
};
/**
@ -429,12 +320,8 @@ export const changeUserPassword = async (data) => {
* @returns {Promise<Object>}
*/
export const resetPasswordExternal = async (data) => {
try {
const response = await api.post('/ambiente_python/reset_password', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao redefinir senha');
}
};
/**
@ -444,12 +331,8 @@ export const resetPasswordExternal = async (data) => {
* @returns {Promise<Object>}
*/
export const requestPasswordReset = async (data) => {
try {
const response = await api.post('/forgot_password', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao solicitar redefinição de senha');
}
};
/**
@ -458,12 +341,8 @@ export const requestPasswordReset = async (data) => {
* @returns {Promise<Object>}
*/
export const getBases = async () => {
try {
const response = await api.get('/bases/apresentar');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar bases');
}
};
/**
@ -474,12 +353,8 @@ export const getBases = async () => {
* @returns {Promise<Object>}
*/
export const saveBase = async (data) => {
try {
const response = await api.post('/bases', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao criar base');
}
};
/**
@ -489,16 +364,12 @@ export const saveBase = async (data) => {
* @returns {Promise<Object>}
*/
export const updateBase = async (data) => {
try {
const payload = {
idbase: data.idbase,
base: data.base
};
const response = await api.put('/bases/editar', payload);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar base');
}
};
/**
@ -508,12 +379,8 @@ export const updateBase = async (data) => {
* @returns {Promise<Object>}
*/
export const deleteBase = async (id) => {
try {
const response = await api.delete('/bases/excluir', { data: { idbase: id } });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao deletar base');
}
};
/**
@ -522,12 +389,8 @@ export const deleteBase = async (id) => {
* @returns {Promise<Object>}
*/
export const getBaseResponsibles = async () => {
try {
const response = await api.get('/responsavel_bases');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao buscar responsáveis');
}
};
/**
@ -538,12 +401,8 @@ export const getBaseResponsibles = async () => {
* @returns {Promise<Object>}
*/
export const saveBaseResponsible = async (data) => {
try {
const response = await api.post('/responsavel_bases', data);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao criar responsável');
}
};
/**
@ -554,16 +413,12 @@ export const saveBaseResponsible = async (data) => {
* @returns {Promise<Object>}
*/
export const updateBaseResponsible = async (data) => {
try {
const payload = {
id_responsavel_base: data.id_responsavel_base,
responsavel_base: data.responsavel_base
};
const response = await api.put('/responsavel_bases/editar', payload);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao atualizar responsável');
}
};
/**
@ -573,12 +428,8 @@ export const updateBaseResponsible = async (data) => {
* @returns {Promise<Object>}
*/
export const deleteBaseResponsible = async (id) => {
try {
const response = await api.delete('/responsavel_bases/excluir', { data: { id_responsavel_base: id } });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao deletar responsável');
}
};
/**
@ -588,15 +439,11 @@ export const deleteBaseResponsible = async (id) => {
* @returns {Promise<Object>}
*/
export const unarchiveDriverRegistration = async (iddrivers) => {
try {
if (!iddrivers) {
throw new Error('ID do cadastro não informado para desarquivamento.');
}
const response = await api.put(`/cadatro/desarquivar/${iddrivers}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erro ao desarquivar cadastro');
}
};
/**
@ -606,14 +453,9 @@ export const unarchiveDriverRegistration = async (iddrivers) => {
* @returns {Promise<Object>}
*/
export const unarchiveContract = async (id) => {
try {
if (!id) {
throw new Error('ID do contrato não informado para desarquivamento.');
}
const response = await api.put(`/contrato/desarquivar/${id}`);
return response.data;
} catch (error) {
const errorMsg = error.response?.data?.message || error.message || 'Erro ao desarquivar contrato';
throw new Error(errorMsg);
}
};

View File

@ -10,54 +10,90 @@ export const handleGrError = (error, defaultMessage = "Ocorreu um erro inesperad
console.error("GR [ERROR]:", error);
let message = defaultMessage;
let description = "";
let status = error.response?.status;
// Check for specific error response structure from axios/api
if (error.response) {
const status = error.response.status;
// 1. Redirecionamento para 401 e 403 (Segurança)
if (status === 401 || status === 403) {
toast.error("Sessão expirada ou acesso negado", {
description: "Por segurança, você será redirecionado para a tela de login."
});
// Pequeno delay para o usuário ler a mensagem antes do redirect
setTimeout(() => {
window.location.href = '/plataforma/gr/login';
}, 2000);
return;
}
// 2. Extração e Sanitização da mensagem do Backend
if (error.response?.data) {
const data = error.response.data;
// Prioritize message from backend if available
// Backend retorna erro em português no campo 'erro' (ex: {erro: "Nenhuma base encontrada..."})
if (data && data.erro) {
message = data.erro;
} else if (data && data.message) {
message = data.message;
} else if (data && data.error) {
message = data.error;
// Tenta extrair mensagem de vários formatos comuns (FastAPI detail, erro, message, etc)
let rawMessage = '';
if (data.detail) {
if (Array.isArray(data.detail)) {
rawMessage = data.detail.map(d => `${d.loc[d.loc.length - 1]}: ${d.msg}`).join(', ');
} else if (typeof data.detail === 'string') {
rawMessage = data.detail;
}
}
// Handle specific status codes if message is generic or missing
if (!message || message === defaultMessage) {
if (!rawMessage) {
rawMessage = data.erro || data.message || data.error || '';
}
// Lista de termos que indicam exposição de código ou logs técnicos
const technicalTerms = ['sql', 'exception', 'traceback', 'internal server error', 'undefined', 'null', 'nan', '{', '[', 'stack', 'database', 'query'];
if (rawMessage && !technicalTerms.some(term => String(rawMessage).toLowerCase().includes(term))) {
// Se tivermos uma mensagem amigável do backend, usamos como descrição detalhada
description = rawMessage;
} else if (rawMessage) {
// Se a mensagem parecer técnica, usamos um fallback amigável
message = "Erro no processamento";
description = "Não foi possível completar a operação devido a um erro interno. Por favor, tente novamente.";
}
}
// 3. Tratamento amigável por Status Code
switch (status) {
case 400:
message = "Dados inválidos. Verifique as informações preenchidas.";
// Se não tiver descrição da sanitização, coloca uma padrão mais amigável
if (!description) description = "Verifique se todos os campos obrigatórios foram preenchidos corretamente e tente salvar novamente.";
break;
case 401:
message = "Sessão expirada. Faça login novamente.";
break;
case 403:
message = "Você não tem permissão para realizar esta ação.";
case 409:
message = "Conflito de Informações";
if (!description) description = "Este registro já existe ou há um conflito com os dados enviados (ex: CPF já cadastrado).";
break;
case 404:
message = "Recurso não encontrado.";
message = "Informação não encontrada";
description = "O registro ou a página que você está tentando acessar não foi localizado ou pode ter sido removido.";
break;
case 500:
message = "Erro Interno (500). Por favor, contate o suporte técnico.";
break;
case 502:
message = "Serviço Indisponível (502). O sistema está temporariamente fora do ar.";
message = "Sistema em Manutenção";
description = "O serviço está temporariamente indisponível. Nossa equipe técnica já foi notificada.";
break;
default:
if (!error.response) {
if (error.code === 'ECONNABORTED' || error.message?.toLowerCase().includes('timeout')) {
message = "Tempo de Resposta Excedido";
description = "O servidor demorou muito para responder. Por favor, verifique sua conexão ou tente novamente via tela de configuração.";
} else {
message = "Falha na Rede";
description = "Não conseguimos conectar ao servidor. Verifique sua conexão com a internet.";
}
}
break;
}
}
} else if (error.message) {
// Handle network errors or other JS errors
message = error.message;
}
// Clean up message (remove prefixes like "Error: ")
message = message.replace(/^Error:\s*/i, '');
toast.error(message);
// 4. Exibição via Sonner de forma Premium
toast.error(message, {
description: description,
duration: 5000,
});
};

View File

@ -3,8 +3,11 @@ import { useNavigate } from 'react-router-dom';
import { Search, Loader2, ArrowLeft, ArchiveRestore } from 'lucide-react';
import ExcelTable from '../components/ExcelTable';
import { toast } from 'sonner';
import { handleGrError } from '../utils/grErrorHandler';
import * as grService from '../services/grService';
const ArchivedContractsView = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
@ -37,8 +40,7 @@ const ArchivedContractsView = () => {
setData(enhancedList);
setBases(Array.isArray(basesList) ? basesList : []);
} catch (error) {
console.error('Error fetching archived contracts:', error);
toast.error(error.message ?? 'Erro ao carregar contratos arquivados.');
handleGrError(error, "Erro ao carregar contratos arquivados.");
setData([]);
} finally {
setLoading(false);
@ -67,8 +69,7 @@ const ArchivedContractsView = () => {
toast.success('Contrato desarquivado com sucesso!');
fetchData();
} catch (error) {
console.error('Error unarchiving contract:', error);
toast.error(error.message || 'Erro ao desarquivar o contrato.');
handleGrError(error, "Erro ao desarquivar o contrato.");
}
};

View File

@ -3,7 +3,10 @@ import { useNavigate, Navigate } from 'react-router-dom';
import { Search, Loader2, ArrowLeft, ArchiveRestore } from 'lucide-react';
import ExcelTable from '../components/ExcelTable';
import { toast } from 'sonner';
import { handleGrError } from '../utils/grErrorHandler';
import * as grService from '../services/grService';
import { useGrStore } from '../services/useGrStore';
const ArchivedRegistrationsView = () => {
@ -32,8 +35,7 @@ const ArchivedRegistrationsView = () => {
const basesList = basesResp.Base_Dados_API || basesResp.dados || basesResp || [];
setBases(Array.isArray(basesList) ? basesList : []);
} catch (error) {
console.error('Error fetching archived registrations:', error);
toast.error(error.message ?? 'Erro ao carregar cadastros arquivados.');
handleGrError(error, "Erro ao carregar cadastros arquivados.");
setData([]);
} finally {
setLoading(false);
@ -62,11 +64,11 @@ const ArchivedRegistrationsView = () => {
toast.success('Cadastro desarquivado com sucesso!');
fetchData();
} catch (error) {
console.error('Error unarchiving registration:', error);
toast.error(error.message || 'Erro ao desarquivar o cadastro.');
handleGrError(error, "Erro ao desarquivar o cadastro.");
}
};
const columns = [
{ field: 'nome_completo', header: 'Nome', width: 200 },
{ field: 'base', header: 'Base', width: 100 },

View File

@ -6,7 +6,10 @@ import { GrKanbanBoard } from '../components/GrKanbanBoard';
import { CadastrosAbertosColumn } from '../components/CadastrosAbertosColumn';
import { useGrStore } from '../services/useGrStore';
import { toast } from 'sonner';
import { handleGrError } from '../utils/grErrorHandler';
import { GrFormSidebar } from '../components/GrFormSidebar';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@ -86,8 +89,7 @@ const ContractsView = () => {
setArchivedContracts([]);
}
} catch (error) {
console.error("Error fetching contracts data:", error);
toast.error("Erro ao carregar dados de contratos.");
handleGrError(error, "Erro ao carregar dados de contratos.");
} finally {
setLoading(false);
}
@ -196,7 +198,7 @@ const ContractsView = () => {
setIsSidebarOpen(false);
fetchData();
} catch (error) {
toast.error(error.message || "Erro ao salvar contrato.");
handleGrError(error, "Erro ao salvar contrato.");
}
};
@ -301,8 +303,7 @@ const ContractsView = () => {
toast.success("Movimentação processada com sucesso!");
fetchData(); // Refresh para garantir paridade com o banco
} catch (error) {
console.error("Error moving card:", error);
toast.error("Erro ao atualizar status.");
handleGrError(error, "Erro ao atualizar status.");
}
};
@ -601,7 +602,7 @@ const ContractsView = () => {
toast.success("Contrato criado com sucesso!");
fetchData();
} catch (error) {
toast.error(error.message || "Erro ao criar contrato.");
handleGrError(error, "Erro ao criar contrato.");
throw error;
}
}}
@ -672,8 +673,7 @@ const ContractsView = () => {
setIsSidebarOpen(false);
fetchData();
} catch (error) {
console.error("Erro ao arquivar contrato:", error);
toast.error(error.message || "Erro ao arquivar contrato.");
handleGrError(error, "Erro ao arquivar contrato.");
}
}}
className="w-full sm:w-auto sm:flex-1 py-4 bg-slate-200 hover:bg-slate-300 text-slate-800 rounded-2xl font-bold uppercase text-xs tracking-widest transition-all shadow-md shadow-slate-400/20 active:scale-95"

View File

@ -4,8 +4,10 @@ import { useGrOps } from '../hooks/useGrOps';
import * as grService from '../services/grService';
import { useAuthContext } from '@/components/shared/AuthProvider';
import { toast } from 'sonner';
import { handleGrError } from '../utils/grErrorHandler';
const GrOutlookView = () => {
const { userData } = useAuthContext();
const user = userData?.usuario || { username: '', email: '' };
@ -34,7 +36,7 @@ const GrOutlookView = () => {
toast.success("Credenciais salvas com sucesso");
setPasswordCred('');
} catch (error) {
toast.error(error.message || "Erro ao salvar credenciais");
handleGrError(error, "Erro ao salvar credenciais");
} finally {
setIsSavingCreds(false);
}
@ -52,7 +54,7 @@ const GrOutlookView = () => {
setResults(resp);
toast.success(`${resp.total_xmls} XMLs processados com sucesso`);
} catch (error) {
toast.error(error.message || "Erro ao processar e-mails");
handleGrError(error, "Erro ao processar e-mails");
} finally {
setIsProcessing(false);
}
@ -73,7 +75,7 @@ const GrOutlookView = () => {
toast.error("XML inválido para o sistema");
}
} catch (error) {
toast.error(error.message || "Erro ao validar XML");
handleGrError(error, "Erro ao validar XML");
} finally {
setIsValidating(false);
}

View File

@ -2,7 +2,10 @@ 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 { toast } from 'sonner';
import { handleGrError } from '../utils/grErrorHandler';
import ExcelTable from '../components/ExcelTable';
import { GrKanbanBoard } from '../components/GrKanbanBoard';
import { GrRegistrationDetails } from '../components/GrRegistrationDetails';
import { useGrStore } from '../services/useGrStore';
@ -66,7 +69,7 @@ const RegistrationsView = () => {
setContracts(Array.isArray(contractData) ? contractData : []);
}
} catch (error) {
console.error("Error fetching GR data:", error);
handleGrError(error, "Não foi possível carregar os cadastros.");
} finally {
setLoading(false);
}
@ -138,8 +141,7 @@ const RegistrationsView = () => {
item.iddrivers === cardId ? { ...item, global_status: apiStatus } : item
));
} catch (error) {
console.error("Error moving card:", error);
alert("Erro ao atualizar status.");
handleGrError(error, "Erro ao atualizar status.");
}
};
@ -151,9 +153,11 @@ const RegistrationsView = () => {
const lowerSearch = searchTerm.toLowerCase();
return data.filter(d =>
d && (
d.nome_completo?.toLowerCase().includes(lowerSearch) ||
d.cpf?.includes(searchTerm) ||
d.placa_veiculo?.toLowerCase().includes(lowerSearch)
)
);
}, [data, searchTerm]);
@ -241,22 +245,24 @@ const RegistrationsView = () => {
// Standard registration mapping for all roles
cards = filteredData
.filter(d => {
if (!d) return false;
// Blocking logic: Agregado/Rentals without signed contract only show in 'Aguardando Análise'
const modalidade = normalizeModalidade(d.modalidade);
const isBlocked = modalidade === 'Agregado' || modalidade === 'Rentals';
if (isBlocked) {
const contract = allContracts.find(c => c.iddrivers === d.iddrivers);
const isSigned = contract?.status_assinatura === 'Assinado' || contract?.global_status === 'Assinados';
const hasSignedContract = contract?.status_assinatura === 'Assinado';
if (!isSigned) {
return col.id === 'aguardando' && col.statusKeys?.includes(d.global_status);
if (!hasSignedContract && col.id !== 'aguardando') {
return false;
}
}
return col.statusKeys?.includes(d.global_status);
const currentStatus = d.global_status || d.STATUS || d.status;
return col.statusKeys.includes(currentStatus);
})
.map(d => {
.map((d, dIdx) => {
const modalidade = normalizeModalidade(d.modalidade);
const isBlocked = modalidade === 'Agregado' || modalidade === 'Rentals';
const contract = allContracts.find(c => c.iddrivers === d.iddrivers);
@ -265,11 +271,11 @@ const RegistrationsView = () => {
return {
...d,
id: d.iddrivers,
title: d.nome_completo,
subtitle: d.cpf,
date: d.data_cadastro,
base: d.base,
id: d.iddrivers || `temp-${dIdx}`,
title: d.nome_completo || 'Sem Nome',
subtitle: d.cpf || 'Sem CPF',
date: d.data_cadastro || d.data_envio || '',
base: d.base || 'Sem Base',
details: [ { label: 'Placa', value: d.placa_veiculo }, { label: 'Modalidade', value: d.modalidade } ],
isBlocked: cardIsBlocked,
status: d.global_status
@ -352,8 +358,7 @@ const RegistrationsView = () => {
fetchData();
setSelectedCard(null);
} catch (error) {
console.error("Error saving GR registration:", error);
alert(error.message || "Erro ao salvar alterações.");
handleGrError(error, "Erro ao salvar alterações.");
}
};
@ -384,8 +389,7 @@ const RegistrationsView = () => {
fetchData();
setSelectedCard(null);
} catch (error) {
console.error("Error progressing status:", error);
alert("Erro ao avançar status.");
handleGrError(error, "Erro ao avançar status.");
}
};
@ -398,7 +402,7 @@ const RegistrationsView = () => {
fetchData();
setSelectedCard(null);
} catch (error) {
alert("Erro ao aprovar.");
handleGrError(error, "Erro ao aprovar.");
}
};
@ -411,7 +415,7 @@ const RegistrationsView = () => {
fetchData();
setSelectedCard(null);
} catch (error) {
alert("Erro ao reprovar.");
handleGrError(error, "Erro ao reprovar.");
}
};
@ -524,7 +528,7 @@ const RegistrationsView = () => {
onCardClick={(card) => {
if (userGroup === 'Despachante' || userGroup === 'Coordenador') {
// Only 'Pendências / Revisão' allows interaction (navigation to form)
if (card.global_status === 'Pendências / Revisão') {
if (card.status === 'Pendências / Revisão') {
sessionStorage.setItem('info', JSON.stringify(card));
navigate('novo');
} else {
@ -584,8 +588,7 @@ const RegistrationsView = () => {
fetchData();
setSelectedCard(null);
} catch (error) {
console.error("Error archiving registration:", error);
toast.error(error.message || "Erro ao arquivar cadastro.");
handleGrError(error, "Erro ao arquivar cadastro.");
}
}}
/>

View File

@ -44,7 +44,8 @@ const SettingsView = () => {
setor: 'Gestão de Risco', // Default as requested
base: [], // Changed to array for multi-select
responsavel_base: '',
grupo: ''
grupo: '',
modalidade: ''
});
const [baseFormData, setBaseFormData] = useState({
@ -117,7 +118,8 @@ const SettingsView = () => {
setor: 'Gestão de Risco',
base: selectedBases,
responsavel_base: user.responsavel_base || user.responsavel || '',
grupo: user.grupo || user.group || ''
grupo: user.grupo || user.group || '',
modalidade: user.modalidade || ''
});
setIsSidebarOpen(true);
};
@ -131,7 +133,8 @@ const SettingsView = () => {
setor: 'Gestão de Risco',
base: [],
responsavel_base: '',
grupo: ''
grupo: '',
modalidade: ''
});
setIsSidebarOpen(true);
};

View File

@ -141,9 +141,9 @@ export const WorkspaceLayout = () => {
isDarkMode ? "bg-[#0a0a0a]/80 border-white/5 shadow-2xl shadow-black/20" : "bg-white/80 border-slate-200"
)}>
<div className="flex items-center gap-4">
<div className="w-9 h-9 flex items-center justify-center">
{/* <div className="w-9 h-9 flex items-center justify-center">
<img src={logo} alt="iTGUYS" className="h-6 w-auto" />
</div>
</div> */}
<h2 className={cn(
"text-sm font-bold uppercase tracking-[0.2em]",
isDarkMode ? "text-white" : "text-slate-800"

View File

@ -170,13 +170,15 @@ export const WorkspaceSidebar = ({ activeScreen, onScreenChange, isCollapsed, on
<footer className="ws-footer">
<div className={cn("ws-brand", isCollapsed && "justify-center mb-0")}>
<div className="ws-brand-logo">
{/* <div className="ws-brand-logo">
<Zap size={20} />
</div>
</div> */}
<img src={logo} alt="iTGUYS" className="h-10 w-auto mb-2 drop-shadow-[0_0_15px_rgba(38,177,199,0.2)]" />
{!isCollapsed && (
<div className="ws-brand-info">
<span className="ws-brand-name">iT<span>GUYS</span></span>
<span className="ws-app-sub">Master Core v4</span>
{/* <span className="ws-app-sub">Master Core v4</span> */}
</div>
)}
</div>

View File

@ -177,6 +177,24 @@ export const boletosService = {
const response = await api.post('/boletos/agendar/individual', payload);
return response.data;
}
}),
/**
* Busca detalhes de juros de um cliente específico
* Rota: GET /financeiro/cliente/boletos
* @param {number|string} idempresa
*/
fetchJurosCliente: (idempresa) => handleRequest({
mockFn: () => simulateLatency([]),
apiFn: async () => {
const response = await api.get('/financeiro/cliente/boletos', {
params: { idempresa }
});
const raw = response?.data ?? response;
// Assume a estrutura padrão de retorno do sistema ou extraído diretamente
const data = raw?.dados ?? raw?.Base_Dados_API ?? raw;
return Array.isArray(data) ? data : [];
}
})
};

View File

@ -327,6 +327,85 @@ export const extratoService = {
const response = await api.get('/financeiro/resumo_mes', { params });
return response?.data ?? response;
}
}),
/**
* Busca dados para o gráfico de planejado vs executado (Receitas)
* Rota: GET /extrato/planejado/grafico
* @param {Object} params - { dia, mes, ano }
*/
fetchPlanejadoGrafico: (params) => handleRequest({
mockFn: () => simulateLatency({ filtro: params, grafico: [] }),
apiFn: async () => {
const response = await api.get('/extrato/planejado/grafico', { params });
return response?.data ?? response;
}
}),
/**
* Busca totais de despesas planejadas para o painel de cruzamento
* Rota: GET /financeiro/despesas_mes
* @param {Object} params - { mes, ano }
*/
fetchDespesasResumo: (params) => handleRequest({
mockFn: () => simulateLatency({
mes: Number(params.mes),
ano: Number(params.ano),
total_planejado: 0,
total_realizado: 0,
diferenca: 0
}),
apiFn: async () => {
const response = await api.get('/financeiro/despesas_mes', { params });
return response?.data ?? response;
}
}),
/**
* Busca dados para o gráfico de planejado vs executado (Despesas)
* Rota: GET /extrato/despesas/grafico
* @param {Object} params - { dia, mes, ano }
*/
fetchDespesasGrafico: (params) => handleRequest({
mockFn: () => simulateLatency({ filtro: params, grafico: [] }),
apiFn: async () => {
const response = await api.get('/extrato/despesas/grafico', { params });
return response?.data ?? response;
}
}),
/**
* Busca detalhamento de juros adicionados
* Rota: GET /extrato/adicionado
* @param {Object} params - { mes, ano }
*/
fetchJurosAdicionados: (params) => handleRequest({
mockFn: () => simulateLatency({ adicionado: [], total_adicionado: 0, total_registros: 0 }),
apiFn: async () => {
const response = await api.get('/extrato/adicionado', { params });
return response?.data ?? response;
}
}),
/**
* Busca transações de um beneficiário específico
* Rota: POST /beneficiario_aplicado
* @param {string} beneficiario_pagador
*/
fetchBeneficiarioAplicado: (beneficiario_pagador) => handleRequest({
mockFn: () => simulateLatency([]),
apiFn: async () => {
const response = await api.post('/beneficiario_aplicado', {
beneficiario_pagador: beneficiario_pagador
});
const raw = response?.data ?? response;
let data = raw?.dados ?? raw?.Base_Dados_API ?? raw;
if (!Array.isArray(data)) {
data = Object.values(data || {});
}
return Array.isArray(data) ? data : [];
}
})
};

View File

@ -107,7 +107,8 @@ export const fornecedoresService = {
cidade: fornecedorData.cidade || '',
uf: fornecedorData.uf || '',
cep: fornecedorData.cep || '',
idregra: fornecedorData.idregra || fornecedorData.regra_id || null
// idregra enviado como lista
idregra: Array.isArray(fornecedorData.idregra) ? fornecedorData.idregra : (fornecedorData.idregra ? [fornecedorData.idregra] : [])
};
const response = await api.post('/fornecedores/create', payload);
@ -145,7 +146,8 @@ export const fornecedoresService = {
cidade: fornecedorData.cidade || '',
uf: fornecedorData.uf || '',
cep: fornecedorData.cep || '',
idregra: fornecedorData.idregra || fornecedorData.regra_id || null
// idregra enviado como lista
idregra: Array.isArray(fornecedorData.idregra) ? fornecedorData.idregra : (fornecedorData.idregra ? [fornecedorData.idregra] : [])
};
const response = await api.post('/fornecedores/edit', payload);
@ -162,17 +164,19 @@ export const fornecedoresService = {
deleteFornecedor: (id) => handleRequest({
mockFn: () => simulateLatency({ success: true }),
apiFn: async () => {
// Tenta primeiro POST /fornecedores/delete
// Tenta primeiro POST /fornecedores/delete enviando idfornecedores no corpo (JSON)
try {
const response = await api.post('/fornecedores/delete', { idfornecedores: id });
console.log('[fornecedoresService] Tentando excluir fornecedor via POST /fornecedores/delete:', id);
const response = await api.delete('/fornecedores/delete', { idfornecedores: id });
return response.data;
} catch (err) {
console.warn('[fornecedoresService] Falha no POST /fornecedores/delete, tentando fallback DELETE:', err.message);
// Fallback: tenta DELETE /fornecedores/:id
try {
const response = await api.delete(`/fornecedores/${id}`);
return response.data;
} catch (err2) {
console.error('[fornecedoresService] Erro ao excluir fornecedor:', err2);
console.error('[fornecedoresService] Erro ao excluir fornecedor (todas as rotas):', err2);
throw err2;
}
}
@ -180,18 +184,23 @@ export const fornecedoresService = {
}),
/**
* Busca movimentações aplicadas a uma regra (usado no extrato do fornecedor)
* Busca movimentações aplicadas a uma ou mais regras (usado no extrato do fornecedor)
* Rota: POST /regra_aplicada
* @param {number|string} idregra - ID da regra/categoria
* @param {number|string|Array} idregra - ID ou lista de IDs de regra
* @param {string} tipoOperacao - 'C' ou 'D'
* @param {Array} [idempresa] - Lista de IDs de empresa (opcional)
* @returns {Promise<Array>}
*/
fetchRegraAplicada: (idregra, tipoOperacao) => handleRequest({
fetchRegraAplicada: (idregra, tipoOperacao, idempresa) => handleRequest({
mockFn: () => simulateLatency([]),
apiFn: async () => {
// Normaliza idregra como array
const regraIds = Array.isArray(idregra) ? idregra : (idregra ? [idregra] : []);
const payload = {
idregra: idregra,
tipoOperacao: tipoOperacao
idregra: regraIds,
tipoOperacao: tipoOperacao,
...(idempresa && Array.isArray(idempresa) && idempresa.length > 0 ? { idempresa } : {})
};
const response = await api.post('/regra_aplicada', payload);