FEAT/FIX: Melhor funcionamento da deifinção de usuários e correção de acentuação

- Front completamente corrigido para tudo estar acentuado
- Melhora na exibição de áreas/especialidades eusuários;
This commit is contained in:
Rafael Alves Lopes 2026-05-21 15:50:55 -03:00
parent c61a913c38
commit 4d287faf28
23 changed files with 873 additions and 547 deletions

View File

@ -289,17 +289,17 @@ export function NewAttendancePage() {
async function handleStartAttendance() {
if (!canStartAttendance) {
setError('Informe um numero de WhatsApp para iniciar o atendimento.');
setError('Informe um número de WhatsApp para iniciar o atendimento.');
return;
}
if (!selectedTemplateId) {
setError('Selecione uma mensagem pre-aprovada para iniciar o atendimento.');
setError('Selecione uma mensagem pré-aprovada para iniciar o atendimento.');
return;
}
if (!currentUserId) {
setError('Nao foi possivel identificar o usuario logado.');
setError('Não foi possível identificar o usuário logado.');
return;
}
@ -367,7 +367,7 @@ export function NewAttendancePage() {
textAlign: 'center',
}}
>
Criacao rapida de atendimento
Criação pida de atendimento
</div>
<Link
to="/home"
@ -412,8 +412,8 @@ export function NewAttendancePage() {
<div>
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
Informe um contato de WhatsApp ou selecione alguem da agenda para iniciar o atendimento.
Para conversas novas, o primeiro envio usa uma mensagem pre-aprovada da Meta.
Informe um contato de WhatsApp ou selecione alguém da agenda para iniciar o atendimento.
Para conversas novas, o primeiro envio usa uma mensagem pré-aprovada da Meta.
</p>
</div>
@ -421,7 +421,7 @@ export function NewAttendancePage() {
type="search"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar contato salvo por nome ou numero"
placeholder="Buscar contato salvo por nome ou número"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -467,7 +467,7 @@ export function NewAttendancePage() {
{channel.label}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{isDisabled ? 'Canal em construcao.' : 'Inicia uma conversa pelo WhatsApp.'}
{isDisabled ? 'Canal em construção.' : 'Inicia uma conversa pelo WhatsApp.'}
</span>
</button>
);
@ -482,7 +482,7 @@ export function NewAttendancePage() {
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Pais</span>
<span style={{ fontWeight: 600 }}>País</span>
<select
value={selectedCountryId}
onChange={(event) => {
@ -510,7 +510,7 @@ export function NewAttendancePage() {
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Numero do WhatsApp</span>
<span style={{ fontWeight: 600 }}>Número do WhatsApp</span>
<input
type="text"
value={form.phone}
@ -569,7 +569,7 @@ export function NewAttendancePage() {
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Mensagem pre-aprovada</span>
<span style={{ fontWeight: 600 }}>Mensagem pré-aprovada</span>
<select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
@ -610,7 +610,7 @@ export function NewAttendancePage() {
) : null}
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Observacao</span>
<span style={{ fontWeight: 600 }}>Observação</span>
<textarea
rows={5}
value={form.note}
@ -665,10 +665,10 @@ export function NewAttendancePage() {
Canal: {selectedChannel.label}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Numero: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Nao informado'}
Número: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Não informado'}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Empresa: {form.company || 'Nao informada'}
Empresa: {form.company || 'Não informada'}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
@ -685,9 +685,9 @@ export function NewAttendancePage() {
gap: '0.7rem',
}}
>
<strong>Proxima rota</strong>
<strong>Próxima rota</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
O contato sera salvo, o template sera enviado e a conversa abrira atribuida a voce no chat.
O contato será salvo, o template será enviado e a conversa abrirá atribuída a você no chat.
</span>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.4rem' }}>
{selectedContactId ? (
@ -703,7 +703,7 @@ export function NewAttendancePage() {
fontWeight: 800,
}}
>
Limpar selecao
Limpar seleção
</button>
) : null}
<button

View File

@ -32,7 +32,7 @@ export function useLogin() {
window.history.replaceState({}, document.title, window.location.pathname);
navigate('/home', { replace: true });
} catch {
setError('Nao foi possivel concluir o login Microsoft.');
setError('Não foi possível concluir o login Microsoft.');
}
}, [navigate]);

View File

@ -147,7 +147,7 @@ export function LoginPage() {
lineHeight: 1.6,
}}
>
Use seu usuario corporativo para acessar o MVP com Active Directory ou Microsoft.
Use seu usuário corporativo para acessar o MVP com Active Directory ou Microsoft.
</p>
</div>
</div>

View File

@ -4,7 +4,7 @@ async function parseJsonResponse(response) {
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.message || 'Nao foi possivel autenticar.');
throw new Error(data?.message || 'Não foi possível autenticar.');
}
return data;

View File

@ -21,7 +21,7 @@ export function CallHeader({ isMobile = false }) {
textAlign: 'center',
}}
>
Ligacao ativa
Ligação ativa
</div>
<div

View File

@ -98,7 +98,7 @@ export function CallPage() {
}}
>
{[
{ label: 'Numero', value: activeCall.number },
{ label: 'Número', value: activeCall.number },
{ label: 'Canal original', value: 'Atendimento omnichannel' },
{ label: 'Responsável atual', value: 'Ana Camolesi' },
].map((item) => (
@ -164,8 +164,8 @@ export function CallPage() {
lineHeight: 1.6,
}}
>
Voce esta em uma ligacao ativa com a cliente. Os controles abaixo sao visuais
neste MVP e ajudam a demonstrar a experiencia de voz do produto.
Você está em uma ligação ativa com a cliente. Os controles abaixo são visuais
neste MVP e ajudam a demonstrar a experiência de voz do produto.
</p>
</div>
@ -187,7 +187,7 @@ export function CallPage() {
color: 'rgba(255, 255, 255, 0.72)',
}}
>
Qualidade da chamada: Estavel
Qualidade da chamada: Estável
</div>
<div
style={{
@ -197,7 +197,7 @@ export function CallPage() {
color: 'rgba(255, 255, 255, 0.72)',
}}
>
Gravacao mock: Habilitada
Gravação mock: Habilitada
</div>
<button
type="button"

View File

@ -40,7 +40,7 @@ export function ChatTransferPanel({
<div>
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Transferir atendimento</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Reencaminhe a conversa para a area ideal.
Reencaminhe a conversa para a especialidade ideal.
</span>
</div>
<button
@ -58,7 +58,7 @@ export function ChatTransferPanel({
</div>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Area</span>
<span style={{ fontWeight: 600 }}>Especialidade</span>
<select value={transferArea} onChange={(event) => setTransferArea(event.target.value)} style={fieldStyle}>
{transferAreas.map((area) => (
<option key={area} value={area}>
@ -91,18 +91,18 @@ export function ChatTransferPanel({
background: 'rgba(0, 49, 80, 0.04)',
}}
>
Ao transferir para outra area, a conversa caira na fila dessa area.
Ao transferir para outra especialidade, a conversa cairá na fila dessa especialidade.
</div>
)}
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Observacao</span>
<span style={{ fontWeight: 600 }}>Observação</span>
<textarea
rows={5}
value={transferNote}
onChange={(event) => setTransferNote(event.target.value)}
placeholder="Contexto opcional para ajudar o proximo atendente."
placeholder="Contexto opcional para ajudar o próximo atendente."
style={{ ...fieldStyle, resize: 'vertical' }}
/>
</label>
@ -119,7 +119,7 @@ export function ChatTransferPanel({
fontWeight: 700,
}}
>
Confirmar transferencia
Confirmar transferência
</button>
</aside>
);

View File

@ -58,7 +58,7 @@ function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
fontWeight: 700,
}}
>
Carregando midia...
Carregando mídia...
</div>
);
}
@ -66,7 +66,7 @@ function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
if (message.mediaError) {
return (
<span style={{ color: isAgent ? '#fff' : 'var(--color-text-soft)', fontWeight: 700 }}>
Nao foi possivel carregar a midia.
Não foi possível carregar a dia.
</span>
);
}
@ -394,7 +394,7 @@ export function ChatWindow({
}}
>
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.25rem' }}>
Observacao da transferencia
Observação da transferência
</strong>
{transferNote}
</div>
@ -556,8 +556,8 @@ export function ChatWindow({
>
<span style={{ display: 'block' }}>
{canAssumeChat
? 'Este atendimento esta na fila. Assuma para responder ou transferir.'
: assignmentLabel || 'Este atendimento esta atribuido a outro usuario.'}
? 'Este atendimento está na fila. Assuma para responder ou transferir.'
: assignmentLabel || 'Este atendimento está atribuído a outro usuário.'}
</span>
{transferNote ? (
<span style={{ display: 'block', marginTop: '0.45rem', color: 'var(--color-text)' }}>

View File

@ -10,7 +10,7 @@ function getUserId(user) {
function formatPhone(phone) {
const digits = String(phone || '').replace(/\D/g, '');
if (!digits) return 'Telefone nao disponivel';
if (!digits) return 'Telefone não disponível';
if (digits.startsWith('55') && digits.length === 13) {
return `+55 (${digits.slice(2, 4)}) ${digits.slice(4, 9)}-${digits.slice(9)}`;
@ -164,12 +164,12 @@ export function ContactProfilePanel({ isOpen, contact, onClose, onSaved }) {
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Observacao</span>
<span style={{ fontWeight: 600 }}>Observação</span>
<textarea
rows={5}
value={form.note}
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
placeholder="Informacoes relevantes do cliente."
placeholder="Informações relevantes do cliente."
style={{ ...fieldStyle, resize: 'vertical' }}
/>
</label>

View File

@ -38,7 +38,7 @@ function getContactName(chat) {
function getPreviewFromMessage(message) {
if (message?.body) return message.body;
if (message?.text) return message.text;
if (message?.hasMedia || message?.media) return '[Midia]';
if (message?.hasMedia || message?.media) return '[Mídia]';
return '';
}
@ -480,7 +480,7 @@ export function useChat() {
function updateContactPreview(contactId, preview, media) {
updateContact(contactId, (contact) => ({
...contact,
preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview,
preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : preview,
time: 'Agora',
unread: 0,
lastMessageFromMe: true,
@ -523,7 +523,7 @@ export function useChat() {
const response = await fetch(
`${API_BASE_URL}/whatsapp/media/${encodeURIComponent(contactId)}/${encodeURIComponent(messageId)}`,
);
if (!response.ok) throw new Error('Falha ao carregar midia.');
if (!response.ok) throw new Error('Falha ao carregar mídia.');
const media = await response.json();
setMessagesByContact((current) => ({
...current,
@ -536,7 +536,7 @@ export function useChat() {
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar midia.' }
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar mídia.' }
: message,
),
}));
@ -559,7 +559,7 @@ export function useChat() {
}),
});
if (!response.ok) throw new Error('Nao foi possivel assumir o atendimento.');
if (!response.ok) throw new Error('Não foi possível assumir o atendimento.');
const assignment = await response.json();
updateContact(contactId, (contact) => ({
...contact,
@ -581,7 +581,7 @@ export function useChat() {
method: 'DELETE',
});
if (!response.ok) throw new Error('Nao foi possivel sair do atendimento.');
if (!response.ok) throw new Error('Não foi possível sair do atendimento.');
const assignment = await response.json();
updateContact(activeContactId, (contact) => ({
...contact,
@ -636,7 +636,7 @@ export function useChat() {
...current,
[contactId]: mergeMessageList(current[contactId] || [], newMessage),
}));
updateContactPreview(contactId, trimmed || '[Midia]', media);
updateContactPreview(contactId, trimmed || '[Mídia]', media);
if (contactId === activeContactId) {
setDraft('');
}
@ -672,7 +672,7 @@ export function useChat() {
const areaId = selectedTransferArea?.id;
if (!areaId) {
setApiError('Selecione uma area valida para transferencia.');
setApiError('Selecione uma especialidade válida para transferência.');
return;
}
@ -694,14 +694,14 @@ export function useChat() {
});
if (!response.ok) {
setApiError('Nao foi possivel transferir o atendimento.');
setApiError('Não foi possível transferir o atendimento.');
return;
}
const assignment = await response.json();
const transferMessage = targetUserId
? `Transferencia solicitada para ${transferArea}. Obs: ${note || 'Sem observacao.'}`
: `Transferencia enviada para a fila de ${transferArea}. Obs: ${note || 'Sem observacao.'}`;
? `Transferência solicitada para ${transferArea}. Obs: ${note || 'Sem observação.'}`
: `Transferência enviada para a fila de ${transferArea}. Obs: ${note || 'Sem observação.'}`;
setMessagesByContact((current) => ({
...current,

View File

@ -27,7 +27,7 @@ export const chatContacts = [
time: '08:15',
unread: 1,
messages: [
{ id: 1, sender: 'customer', text: 'Recebi a cobranca em duplicidade.' },
{ id: 1, sender: 'customer', text: 'Recebi a cobrança em duplicidade.' },
{ id: 2, sender: 'agent', text: 'Vou analisar isso agora para você.' },
{ id: 3, sender: 'customer', text: 'Pode me ligar em 10 minutos?' },
],
@ -67,7 +67,7 @@ export function getMockReply(contactName) {
const replies = [
`Perfeito, obrigado pelo retorno, ${contactName.split(' ')[0]}.`,
'Tudo bem, fico no aguardo dessa confirmação.',
'Entendi. Se precisar, posso encaminhar para a área responsável.',
'Entendi. Se precisar, posso encaminhar para a especialidade responsável.',
];
return replies[Math.floor(Math.random() * replies.length)];

View File

@ -42,7 +42,7 @@ export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false
fontWeight: 700,
}}
>
Nova ligacao
Nova ligação
</button>
</div>

View File

@ -25,7 +25,7 @@ export function HomeTopbar({
const [currentDateTime, setCurrentDateTime] = useState(() => formatCurrentDateTime(new Date()));
const tabs = [
{ id: 'messages', label: 'Mensagens' },
{ id: 'calls', label: 'Ligacoes' },
{ id: 'calls', label: 'Ligações' },
];
const gridTemplateColumns = isMobile

View File

@ -56,7 +56,7 @@ function UnreadBadge({ count }) {
function buildSuggestedReplies(conversation) {
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
const firstName = conversation?.name?.split(' ')?.[0] || 'voce';
const firstName = conversation?.name?.split(' ')?.[0] || 'você';
const lowerContext = lastMessage.toLowerCase();
if (
@ -65,8 +65,8 @@ function buildSuggestedReplies(conversation) {
lowerContext.includes('pagamento')
) {
return [
`${firstName}, vou conferir os dados financeiros e ja te retorno com a posicao correta.`,
'Recebi sua mensagem sobre cobranca. Vou validar o historico antes de seguir com a orientacao.',
`${firstName}, vou conferir os dados financeiros e já te retorno com a posição correta.`,
'Recebi sua mensagem sobre cobrança. Vou validar o histórico antes de seguir com a orientação.',
'Consigo te ajudar com isso. Pode me confirmar o CPF/CNPJ ou protocolo vinculado ao atendimento?',
];
}
@ -77,9 +77,9 @@ function buildSuggestedReplies(conversation) {
lowerContext.includes('atualizar')
) {
return [
`${firstName}, vou validar seu cadastro e confirmar se a alteracao ja foi registrada.`,
'Para seguir com a atualizacao, me confirme por favor os dados que precisam ser ajustados.',
'Entendi. Vou verificar o cadastro atual e te retorno com o proximo passo.',
`${firstName}, vou validar seu cadastro e confirmar se a alteração já foi registrada.`,
'Para seguir com a atualização, me confirme por favor os dados que precisam ser ajustados.',
'Entendi. Vou verificar o cadastro atual e te retorno com o próximo passo.',
];
}
@ -89,16 +89,16 @@ function buildSuggestedReplies(conversation) {
lowerContext.includes('retorno')
) {
return [
`${firstName}, consigo organizar esse retorno. Qual o melhor horario para contato?`,
'Vou registrar sua solicitacao e direcionar o retorno para o time responsavel.',
`${firstName}, consigo organizar esse retorno. Qual o melhor horário para contato?`,
'Vou registrar sua solicitação e direcionar o retorno para o time responsável.',
'Obrigado pelo aviso. Vou confirmar disponibilidade e te retorno por aqui.',
];
}
return [
`${firstName}, recebi sua mensagem e vou verificar o contexto para te orientar corretamente.`,
'Entendi. Vou analisar as informacoes do atendimento e retorno com o melhor encaminhamento.',
'Posso acionar o time responsavel e te atualizar por aqui assim que tiver uma posicao.',
'Entendi. Vou analisar as informações do atendimento e retorno com o melhor encaminhamento.',
'Posso acionar o time responsável e te atualizar por aqui assim que tiver uma posição.',
];
}
@ -171,8 +171,8 @@ export function MessagesWorkspace({
},
{
id: 'script',
title: 'Atualizacao de script',
text: 'Use o novo roteiro de confirmacao de dados em atendimentos financeiros.',
title: 'Atualização de script',
text: 'Use o novo roteiro de confirmação de dados em atendimentos financeiros.',
},
];
@ -279,7 +279,7 @@ export function MessagesWorkspace({
<div>
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Ultimos 3 atendimentos em tempo real.
Últimos 3 atendimentos em tempo real.
</p>
</div>
@ -524,7 +524,7 @@ export function MessagesWorkspace({
<button
type="button"
onClick={selectNextReply}
title="Proxima resposta"
title="Próxima resposta"
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
@ -585,11 +585,11 @@ export function MessagesWorkspace({
))}
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Anotacao rapida</span>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Anotação pida</span>
<textarea
value={noteDraft}
onChange={(event) => setNoteDraft(event.target.value)}
placeholder="Ex: cliente pediu retorno apos as 15h"
placeholder="Ex: cliente pediu retorno após as 15h"
rows={4}
style={{
border: '1px solid var(--color-border)',
@ -618,7 +618,7 @@ export function MessagesWorkspace({
opacity: currentUserId ? 1 : 0.55,
}}
>
Salvar anotacao
Salvar anotação
</button>
{notesError ? (
@ -646,7 +646,7 @@ export function MessagesWorkspace({
<button
type="button"
onClick={() => removeNote(note.id)}
title="Excluir anotacao"
title="Excluir anotação"
style={{
border: 'none',
borderRadius: 999,
@ -664,7 +664,7 @@ export function MessagesWorkspace({
</article>
))
) : (
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma anotacao salva.</span>
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma anotação salva.</span>
)}
</div>
</div>

View File

@ -22,7 +22,7 @@ function toHomeConversation(contact, messages = []) {
messages: messages.map((message) => ({
id: message.id,
from: message.sender === 'agent' ? 'agent' : 'customer',
text: message.text || (message.hasMedia ? '[Midia]' : ''),
text: message.text || (message.hasMedia ? '[Mídia]' : ''),
timestamp: message.timestamp,
})),
};

View File

@ -45,12 +45,12 @@ export function UnassignedHomePage() {
fontWeight: 800,
}}
>
Acesso aguardando configuracao
Acesso aguardando configuração
</span>
<h1 style={{ margin: 0, fontSize: '2rem' }}>Seu usuario ainda nao tem atribuicoes</h1>
<h1 style={{ margin: 0, fontSize: '2rem' }}>Seu usuário ainda não tem atribuições</h1>
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.7 }}>
O login foi realizado, mas um administrador ainda precisa vincular seu usuario a um
perfil de acesso e a uma area operacional antes de liberar a plataforma.
O login foi realizado, mas um administrador ainda precisa vincular seu usuário a um
perfil de acesso e uma especialidade operacional antes de liberar a plataforma.
</p>
</div>
@ -63,8 +63,8 @@ export function UnassignedHomePage() {
gap: '0.65rem',
}}
>
<span style={{ color: 'var(--color-text-soft)' }}>Usuario autenticado</span>
<strong>{user?.name || user?.username || 'Usuario'}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>Usuário autenticado</span>
<strong>{user?.name || user?.username || 'Usuário'}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{user?.email || user?.username || 'Sem email informado'}
</span>

View File

@ -3,7 +3,7 @@ import { API_BASE_URL } from '../../../shared/services/apiConfig';
export async function listAgentNotes(userId) {
if (!userId) return [];
const response = await fetch(`${API_BASE_URL}/agent/notes?userId=${encodeURIComponent(userId)}`);
if (!response.ok) throw new Error('Falha ao carregar anotacoes.');
if (!response.ok) throw new Error('Falha ao carregar anotações.');
return response.json();
}
@ -13,7 +13,7 @@ export async function createAgentNote(userId, text) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, text }),
});
if (!response.ok) throw new Error('Falha ao salvar anotacao.');
if (!response.ok) throw new Error('Falha ao salvar anotação.');
return response.json();
}
@ -22,6 +22,6 @@ export async function deleteAgentNote(userId, noteId) {
`${API_BASE_URL}/agent/notes/${encodeURIComponent(noteId)}?userId=${encodeURIComponent(userId)}`,
{ method: 'DELETE' },
);
if (!response.ok) throw new Error('Falha ao excluir anotacao.');
if (!response.ok) throw new Error('Falha ao excluir anotação.');
return response.json();
}

View File

@ -52,7 +52,7 @@ export const conversations = [
];
export const actionItems = [
{ title: 'Área atual', value: 'Suporte' },
{ title: 'Especialidade atual', value: 'Suporte' },
{ title: 'SLA restante', value: '18 min' },
{ title: 'Prioridade', value: 'Alta' },
];

View File

@ -6,32 +6,32 @@ const navigationBySection = {
supervisor: [
{ id: 'dashboard', label: 'Dashboard', count: null },
{ id: 'queues', label: 'Filas em tempo real', count: 42 },
{ id: 'areas', label: 'Areas supervisionadas', count: 3 },
{ id: 'areas', label: 'Especialidades supervisionadas', count: 3 },
{ id: 'agents', label: 'Agentes online', count: 18 },
{ id: 'reports', label: 'Relatorios', count: null },
{ id: 'reports', label: 'Relatórios', count: null },
],
admin: [
{ id: 'home', label: 'Home' },
{ id: 'today', label: 'Operacao' },
{ id: 'today', label: 'Operação' },
{ type: 'separator' },
{ id: 'users-access', label: 'Usuarios & Acessos' },
{ id: 'users-access', label: 'Usuários & Acessos' },
{ id: 'templates', label: 'Templates' },
{ id: 'knowledge', label: 'Base de conhecimento IA' },
{ id: 'audit', label: 'Auditoria' },
{ id: 'channels', label: 'Canais' },
{ type: 'separator' },
{ id: 'attendance', label: 'Atendimento', path: '/chat' },
{ id: 'attendance', label: 'Atendimento' },
{ id: 'new-attendance', label: 'Abrir Atendimento', path: '/new-attendance' },
{ id: 'mass-message', label: 'Disparo em Massa' },
{ id: 'contacts', label: 'Contatos' },
{ type: 'separator' },
{ id: 'settings', label: 'Configuracoes' },
{ id: 'settings', label: 'Configurações' },
],
};
const actionLabelBySection = {
supervisor: '+ Redistribuir atendimento',
admin: '+ Nova configuracao',
admin: '+ Nova configuração',
};
export function ManagementLayout({
@ -48,7 +48,7 @@ export function ManagementLayout({
}) {
const navigate = useNavigate();
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
const actionLabel = actionLabelBySection[activeSection] || '+ Nova acao';
const actionLabel = actionLabelBySection[activeSection] || '+ Nova ação';
function handleLogout() {
clearSession();
@ -242,7 +242,7 @@ export function ManagementLayout({
<div style={{ textAlign: 'right' }}>
<strong style={{ display: 'block' }}>{profileLabel}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Ambiente de gestao
Ambiente de gestão
</span>
</div>
<div

View File

@ -0,0 +1,330 @@
import { useMemo, useState } from 'react';
import { DataPanel } from './DataPanel';
import { MetricGrid } from './MetricGrid';
const selectStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.75rem 0.85rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
const areaOptions = [
{ value: 'all', label: 'Todas as especialidades' },
{ value: 'Suporte', label: 'Suporte' },
{ value: 'Comercial', label: 'Comercial' },
{ value: 'Financeiro', label: 'Financeiro' },
];
const operationMockByArea = {
all: {
kpis: [
{ label: 'Finalizados hoje', value: '126', detail: 'todos os canais' },
{ label: 'Em aberto agora', value: '42', detail: '18 em atendimento' },
{ label: 'Na fila', value: '12', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '8m 12s', detail: 'TMA operacional' },
{ label: 'Atendentes online', value: '8 de 11', detail: '3 em pausa/offline' },
],
team: [
{ id: 'ana', name: 'Ana Camolesi', status: 'online', open: 4, lastClosed: '6 min' },
{ id: 'rafael', name: 'Rafael Lopes', status: 'online', open: 3, lastClosed: '12 min' },
{ id: 'marina', name: 'Marina Alves', status: 'paused', open: 1, lastClosed: '18 min' },
{ id: 'camila', name: 'Camila Rocha', status: 'online', open: 5, lastClosed: '22 min' },
{ id: 'joao', name: 'Joao Pedro', status: 'offline', open: 0, lastClosed: '1h 05min' },
],
queue: [
{ id: 'q1', channel: 'WhatsApp', contact: 'Maria Souza', waitingMinutes: 24 },
{ id: 'q2', channel: 'Email', contact: 'Empresa Alpha', waitingMinutes: 18 },
{ id: 'q3', channel: 'SMS', contact: 'Carlos Nunes', waitingMinutes: 11 },
{ id: 'q4', channel: 'WhatsApp', contact: 'Grupo Solaris', waitingMinutes: 7 },
],
hourly: [8, 11, 15, 13, 19, 22, 18, 26, 24, 31, 28],
},
Suporte: {
kpis: [
{ label: 'Finalizados hoje', value: '58', detail: 'suporte técnico' },
{ label: 'Em aberto agora', value: '21', detail: '9 em atendimento' },
{ label: 'Na fila', value: '7', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '9m 04s', detail: 'TMA suporte' },
{ label: 'Atendentes online', value: '4 de 6', detail: '1 pausa, 1 offline' },
],
team: [
{ id: 'ana', name: 'Ana Camolesi', status: 'online', open: 4, lastClosed: '6 min' },
{ id: 'rafael', name: 'Rafael Lopes', status: 'online', open: 3, lastClosed: '12 min' },
{ id: 'beatriz', name: 'Beatriz Lima', status: 'paused', open: 2, lastClosed: '21 min' },
{ id: 'pedro', name: 'Pedro Santos', status: 'offline', open: 0, lastClosed: '48 min' },
],
queue: [
{ id: 's1', channel: 'WhatsApp', contact: 'Maria Souza', waitingMinutes: 24 },
{ id: 's2', channel: 'WhatsApp', contact: 'Bruno Matos', waitingMinutes: 15 },
{ id: 's3', channel: 'Email', contact: 'TI Alpha', waitingMinutes: 9 },
],
hourly: [4, 5, 7, 6, 10, 12, 9, 15, 13, 18, 16],
},
Comercial: {
kpis: [
{ label: 'Finalizados hoje', value: '39', detail: 'leads e propostas' },
{ label: 'Em aberto agora', value: '13', detail: '6 em atendimento' },
{ label: 'Na fila', value: '3', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '7m 38s', detail: 'TMA comercial' },
{ label: 'Atendentes online', value: '3 de 3', detail: 'time completo' },
],
team: [
{ id: 'camila', name: 'Camila Rocha', status: 'online', open: 5, lastClosed: '22 min' },
{ id: 'lucas', name: 'Lucas Nunes', status: 'online', open: 4, lastClosed: '14 min' },
{ id: 'helena', name: 'Helena Costa', status: 'online', open: 4, lastClosed: '31 min' },
],
queue: [
{ id: 'c1', channel: 'WhatsApp', contact: 'Grupo Solaris', waitingMinutes: 17 },
{ id: 'c2', channel: 'Email', contact: 'Empresa Beta', waitingMinutes: 10 },
{ id: 'c3', channel: 'SMS', contact: 'Renata Prado', waitingMinutes: 4 },
],
hourly: [2, 4, 6, 5, 8, 7, 8, 10, 11, 13, 12],
},
Financeiro: {
kpis: [
{ label: 'Finalizados hoje', value: '29', detail: 'faturas e boletos' },
{ label: 'Em aberto agora', value: '8', detail: '3 em atendimento' },
{ label: 'Na fila', value: '2', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '6m 51s', detail: 'TMA financeiro' },
{ label: 'Atendentes online', value: '1 de 2', detail: '1 offline' },
],
team: [
{ id: 'marina', name: 'Marina Alves', status: 'paused', open: 1, lastClosed: '18 min' },
{ id: 'joao', name: 'Joao Pedro', status: 'offline', open: 0, lastClosed: '1h 05min' },
{ id: 'roberto', name: 'Roberto Pera', status: 'online', open: 3, lastClosed: '9 min' },
],
queue: [
{ id: 'f1', channel: 'WhatsApp', contact: 'Joao Pedro', waitingMinutes: 22 },
{ id: 'f2', channel: 'Email', contact: 'Financeiro Omega', waitingMinutes: 8 },
],
hourly: [2, 2, 2, 2, 4, 5, 4, 6, 5, 7, 6],
},
};
const statusMeta = {
online: { label: 'Online', background: 'rgba(34, 197, 94, 0.12)', color: '#15803d' },
paused: { label: 'Pausado', background: 'rgba(229, 162, 42, 0.16)', color: '#8a5a00' },
offline: { label: 'Offline', background: 'rgba(100, 116, 139, 0.14)', color: '#475569' },
};
const channelColors = {
WhatsApp: { background: 'rgba(43, 183, 65, 0.12)', color: '#15803d' },
Email: { background: 'rgba(229, 162, 42, 0.16)', color: '#8a5a00' },
SMS: { background: 'rgba(0, 164, 183, 0.12)', color: 'var(--color-primary)' },
};
function Badge({ children, tone }) {
return (
<span
style={{
width: 'fit-content',
borderRadius: 999,
padding: '0.25rem 0.6rem',
background: tone.background,
color: tone.color,
fontWeight: 800,
fontSize: '0.82rem',
}}
>
{children}
</span>
);
}
function HourlyLineChart({ values }) {
const labels = ['08h', '09h', '10h', '11h', '12h', '13h', '14h', '15h', '16h', '17h', '18h'];
const maxValue = Math.max(...values, 1);
const points = values
.map((value, index) => {
const x = (index / (values.length - 1)) * 100;
const y = 100 - (value / maxValue) * 78 - 10;
return `${x},${y}`;
})
.join(' ');
return (
<div style={{ display: 'grid', gap: '0.75rem' }}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: 260 }}>
<polyline points={points} fill="none" stroke="var(--color-secondary)" strokeWidth="2.4" vectorEffect="non-scaling-stroke" />
</svg>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${labels.length}, 1fr)`, gap: '0.25rem', color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 700 }}>
{labels.map((label) => (
<span key={label} style={{ textAlign: 'center' }}>{label}</span>
))}
</div>
</div>
);
}
export function OperationalDashboard({ isDesktop, isMobile }) {
const [selectedArea, setSelectedArea] = useState('all');
const [assignmentTarget, setAssignmentTarget] = useState(null);
const data = operationMockByArea[selectedArea] || operationMockByArea.all;
const onlineAgents = useMemo(
() => data.team.filter((agent) => agent.status === 'online'),
[data.team],
);
const sortedQueue = useMemo(
() => [...data.queue].sort((a, b) => b.waitingMinutes - a.waitingMinutes),
[data.queue],
);
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<label style={{ display: 'grid', gap: '0.35rem', width: isMobile ? '100%' : 260 }}>
<span style={{ fontWeight: 700 }}>Filtro por especialidade</span>
<select value={selectedArea} onChange={(event) => setSelectedArea(event.target.value)} style={selectStyle}>
{areaOptions.map((area) => (
<option key={area.value} value={area.value}>{area.label}</option>
))}
</select>
</label>
</div>
<MetricGrid metrics={data.kpis} minCardWidth="160px" />
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? 'minmax(0, 1.15fr) minmax(360px, 0.85fr)' : '1fr', gap: '1rem', alignItems: 'start' }}>
<DataPanel title="Painel do time" description="Status operacional em tempo real simulado.">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 620 }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
<th style={{ padding: '0.65rem 0.75rem' }}>Nome</th>
<th style={{ padding: '0.65rem 0.75rem' }}>Status</th>
<th style={{ padding: '0.65rem 0.75rem' }}>Atendimentos abertos</th>
<th style={{ padding: '0.65rem 0.75rem' }}>Último finalizado </th>
</tr>
</thead>
<tbody>
{data.team.map((agent) => (
<tr key={agent.id} style={{ borderTop: '1px solid var(--color-border)' }}>
<td style={{ padding: '0.8rem 0.75rem', fontWeight: 800 }}>{agent.name}</td>
<td style={{ padding: '0.8rem 0.75rem' }}>
<Badge tone={statusMeta[agent.status]}>{statusMeta[agent.status].label}</Badge>
</td>
<td style={{ padding: '0.8rem 0.75rem' }}>{agent.open}</td>
<td style={{ padding: '0.8rem 0.75rem', color: 'var(--color-text-soft)', fontWeight: 700 }}>{agent.lastClosed}</td>
</tr>
))}
</tbody>
</table>
</div>
</DataPanel>
<DataPanel title="Fila de espera" description="Conversas não assumidas, ordenadas pelo maior tempo de espera.">
<div style={{ display: 'grid', gap: '0.65rem' }}>
{sortedQueue.map((item) => (
<article
key={item.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.8rem',
background: '#fff',
display: 'grid',
gridTemplateColumns: 'auto minmax(0, 1fr) auto',
gap: '0.75rem',
alignItems: 'center',
}}
>
<Badge tone={channelColors[item.channel]}>{item.channel}</Badge>
<div style={{ minWidth: 0 }}>
<strong style={{ display: 'block' }}>{item.contact}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 700 }}>
Aguardando {item.waitingMinutes} min
</span>
</div>
<button
type="button"
onClick={() => setAssignmentTarget(item)}
style={{
border: 'none',
borderRadius: 12,
padding: '0.65rem 0.8rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Atribuir
</button>
</article>
))}
</div>
</DataPanel>
</div>
<DataPanel title="Atendimentos finalizados por hora" description="Volume do dia entre 08h e 18h.">
<HourlyLineChart values={data.hourly} />
</DataPanel>
{assignmentTarget ? (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 49, 80, 0.28)',
display: 'grid',
placeItems: 'center',
padding: '1rem',
zIndex: 20,
}}
>
<div style={{ width: 'min(460px, 100%)', background: '#fff', borderRadius: 22, padding: '1.25rem', boxShadow: 'var(--shadow-lg)', display: 'grid', gap: '1rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.2rem' }}>Atribuir atendimento</h2>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Selecione um atendente online para {assignmentTarget.contact}.
</p>
</div>
<div style={{ display: 'grid', gap: '0.55rem' }}>
{onlineAgents.length ? onlineAgents.map((agent) => (
<button
key={agent.id}
type="button"
onClick={() => setAssignmentTarget(null)}
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.8rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 800,
textAlign: 'left',
}}
>
{agent.name}
</button>
)) : (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Nenhum atendente online nesta especialidade.</span>
)}
</div>
<button
type="button"
onClick={() => setAssignmentTarget(null)}
style={{
border: 'none',
borderRadius: 14,
padding: '0.8rem 1rem',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Fechar
</button>
</div>
</div>
) : null}
</section>
);
}

View File

@ -3,22 +3,25 @@ import { DataPanel } from '../components/DataPanel';
import { ManagementLayout } from '../components/ManagementLayout';
import { ManagementTable } from '../components/ManagementTable';
import { MetricGrid } from '../components/MetricGrid';
import { OperationalDashboard } from '../components/OperationalDashboard';
import { aiContentRows, areaRows, userRows } from '../services/managementMocks';
import { AttendantOpsPanel } from '../../home/components/AttendantOpsPanel';
import { MessagesWorkspace } from '../../home/components/MessagesWorkspace';
import { useChat } from '../../chat/hooks/useChat';
import {
createAccessArea,
getAccessAreas,
getAccessOptions,
getAccessUsers,
getAdminOverview,
updateAccessArea,
updateUserAccess,
} from '../services/adminAccessService';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
const contentColumns = [
{ key: 'title', label: 'Conteudo' },
{ key: 'area', label: 'Area' },
{ key: 'title', label: 'Conteúdo' },
{ key: 'area', label: 'Especialidade' },
{ key: 'status', label: 'Status' },
{ key: 'updatedAt', label: 'Atualizado' },
];
@ -33,12 +36,19 @@ const selectStyle = {
fontWeight: 600,
};
const compactSelectStyle = {
...selectStyle,
borderRadius: '10px',
padding: '0.45rem 0.55rem',
fontSize: '0.82rem',
};
const monthlyKpis = [
{ label: 'Total de Atendimentos', value: '1.284', detail: '+12% vs mes anterior' },
{ label: 'Tempo Medio de Atendimento', value: '8m 42s', detail: 'media mensal' },
{ label: 'Taxa de Satisfacao', value: '91%', detail: 'avaliacoes positivas' },
{ label: 'Total de Atendimentos', value: '1.284', detail: '+12% vs mês anterior' },
{ label: 'Tempo Médio de Atendimento', value: '8m 42s', detail: 'média mensal' },
{ label: 'Taxa de Satisfação', value: '91%', detail: 'avaliações positivas' },
{ label: 'Volume por Canal', value: 'W 982 · E 184 · S 118', detail: 'WhatsApp · Email · SMS' },
{ label: 'Atendentes Ativos', value: '14 de 17', detail: 'ativos no mes' },
{ label: 'Atendentes Ativos', value: '14 de 17', detail: 'ativos no mês' },
];
const dailyAttendance = [28, 34, 42, 39, 51, 47, 58, 62, 55, 69, 73, 66, 71, 88, 79, 84, 91, 86, 94, 101, 97, 108, 112, 104, 118, 123, 116, 129, 134, 141];
@ -82,8 +92,81 @@ function formatMinutes(minutes) {
return `${Number(minutes)} min`;
}
function toHomeConversation(contact, messages = []) {
return {
id: contact.id,
name: contact.name,
channel: contact.channel || 'WhatsApp',
status: contact.status || 'online',
lastMessage: contact.preview || messages[messages.length - 1]?.text || '',
unread: contact.unread || 0,
time: contact.time || 'Agora',
lastSeen: contact.lastSeen,
messages: messages.map((message) => ({
id: message.id,
from: message.sender === 'agent' ? 'agent' : 'customer',
text: message.text || (message.hasMedia ? '[Mídia]' : ''),
timestamp: message.timestamp,
})),
};
}
function AdminAttendanceWorkspace({ isWideDesktop, isDesktop, isTablet, isMobile }) {
const {
contacts,
activeContactId,
setActiveContactId,
messages,
sendMessage,
isLoadingChats,
} = useChat();
const conversations = contacts.map((contact) =>
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
);
const safeConversationId =
conversations.find((conversation) => conversation.id === activeContactId)?.id ||
conversations[0]?.id;
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<AttendantOpsPanel activeChatsCount={conversations.length} />
{isLoadingChats ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 18,
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Atualizando conversas do WhatsApp...
</div>
) : null}
<MessagesWorkspace
conversations={conversations}
activeConversationId={safeConversationId}
onSelectConversation={setActiveContactId}
onSendSuggestedReply={async (conversationId, reply) => {
setActiveContactId(conversationId);
await sendMessage(reply, conversationId);
}}
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isTablet={isTablet}
isMobile={isMobile}
/>
</section>
);
}
export function AdminPage() {
const { isDesktop, isMobile } = useViewport();
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const [activeAdminSection, setActiveAdminSection] = useState('home');
const [selectedAreaFilter, setSelectedAreaFilter] = useState('all');
@ -96,9 +179,12 @@ export function AdminPage() {
const [areaRowsState, setAreaRowsState] = useState(areaRows);
const [userSearch, setUserSearch] = useState('');
const [newAreaName, setNewAreaName] = useState('');
const [newAreaOwnerId, setNewAreaOwnerId] = useState('');
const [isLoadingAccess, setIsLoadingAccess] = useState(true);
const [accessError, setAccessError] = useState('');
const [editingUser, setEditingUser] = useState(null);
const [editUserProfileId, setEditUserProfileId] = useState('');
const [editUserSpecialties, setEditUserSpecialties] = useState([]);
const [specialtyToAdd, setSpecialtyToAdd] = useState('');
useEffect(() => {
let isMounted = true;
@ -140,30 +226,35 @@ export function AdminPage() {
};
}, []);
async function handleAccessChange(user, field, value) {
const currentPerfilId = user.perfilPrincipal?.id || null;
const currentAreaId = user.areaPrincipal?.id || null;
const nextAccess = {
perfilId: field === 'perfil' ? Number(value) || null : currentPerfilId,
areaId: field === 'area' ? Number(value) || null : currentAreaId,
};
function openUserEditor(user) {
setEditingUser(user);
setEditUserProfileId(user.perfilPrincipal?.id ? String(user.perfilPrincipal.id) : '');
setEditUserSpecialties(Array.isArray(user.areas) ? user.areas : []);
setSpecialtyToAdd('');
}
setUsers((current) =>
current.map((item) =>
item.id === user.id
? {
...item,
perfilPrincipal:
profiles.find((profile) => profile.id === nextAccess.perfilId) || null,
areaPrincipal: areas.find((area) => area.id === nextAccess.areaId) || null,
accessStatus: nextAccess.perfilId && nextAccess.areaId ? 'assigned' : 'unassigned',
}
: item,
),
);
function closeUserEditor() {
setEditingUser(null);
setEditUserProfileId('');
setEditUserSpecialties([]);
setSpecialtyToAdd('');
}
function getProfileName(profileId) {
return profiles.find((profile) => profile.id === Number(profileId))?.nome || '';
}
async function saveUserAccess(user, nextProfileId, nextSpecialties) {
try {
const updatedUser = await updateUserAccess(user.id, nextAccess);
const updatedUser = await updateUserAccess(user.id, {
perfilIds: nextProfileId ? [Number(nextProfileId)] : [],
especialidades: nextSpecialties.map((specialty, index) => ({
areaId: Number(specialty.id),
funcao: specialty.funcao || 'Agente',
principal: index === 0,
ativo: true,
})),
});
if (updatedUser) {
setUsers((current) =>
@ -172,11 +263,53 @@ export function AdminPage() {
}
setAccessError('');
await refreshAreas();
} catch {
setAccessError('Nao foi possivel salvar a atribuicao. Confira o backend.');
setAccessError('Não foi possível salvar a atribuição. Confira o backend.');
}
}
function handleEditProfileChange(value) {
setEditUserProfileId(value);
if (getProfileName(value) === 'Admin') {
setEditUserSpecialties([]);
setSpecialtyToAdd('');
}
}
function addSpecialtyToEdit() {
const area = areas.find((item) => item.id === Number(specialtyToAdd));
if (!area) return;
setEditUserSpecialties((current) => {
if (current.some((specialty) => specialty.id === area.id)) return current;
return [
...current,
{ id: area.id, nome: area.nome, funcao: 'Agente', principal: current.length === 0 },
];
});
setSpecialtyToAdd('');
}
function removeSpecialtyFromEdit(areaId) {
setEditUserSpecialties((current) => current.filter((specialty) => specialty.id !== areaId));
}
function updateEditSpecialtyRole(areaId, role) {
setEditUserSpecialties((current) =>
current.map((specialty) =>
specialty.id === areaId ? { ...specialty, funcao: role } : specialty,
),
);
}
async function submitUserEditor() {
if (!editingUser) return;
const isAdmin = getProfileName(editUserProfileId) === 'Admin';
await saveUserAccess(editingUser, editUserProfileId, isAdmin ? [] : editUserSpecialties);
closeUserEditor();
}
async function refreshAreas() {
const [accessAreas, options] = await Promise.all([getAccessAreas(), getAccessOptions()]);
setAreaRowsState(accessAreas || []);
@ -190,28 +323,12 @@ export function AdminPage() {
try {
await createAccessArea({
nome,
responsavelUsuarioId: Number(newAreaOwnerId) || null,
});
setNewAreaName('');
setNewAreaOwnerId('');
await refreshAreas();
setAccessError('');
} catch {
setAccessError('Nao foi possivel criar a area.');
}
}
async function handleAreaOwnerChange(areaId, userId) {
try {
await updateAccessArea(areaId, {
responsavelUsuarioId: Number(userId) || null,
});
await refreshAreas();
const accessUsers = await getAccessUsers();
setUsers(accessUsers || []);
setAccessError('');
} catch {
setAccessError('Nao foi possivel atualizar o responsavel da area.');
setAccessError('Não foi possível criar a especialidade.');
}
}
@ -220,18 +337,18 @@ export function AdminPage() {
label: 'Total de Atendimentos',
value: overview ? String(overview.totalAttendances) : '...',
detail: overview?.previousMonthVariation === null || overview?.previousMonthVariation === undefined
? 'sem base do mes anterior'
: `${overview.previousMonthVariation >= 0 ? '+' : ''}${overview.previousMonthVariation}% vs mes anterior`,
? 'sem base do mês anterior'
: `${overview.previousMonthVariation >= 0 ? '+' : ''}${overview.previousMonthVariation}% vs mês anterior`,
},
{
label: 'TMA',
value: formatMinutes(overview?.avgHandlingMinutes),
detail: overview?.avgHandlingMinutes === null ? 'aguardando historico' : 'media mensal',
detail: overview?.avgHandlingMinutes === null ? 'aguardando histórico' : 'média mensal',
},
{
label: 'TME',
value: formatMinutes(overview?.avgFirstResponseMinutes),
detail: 'tempo medio de espera',
detail: 'tempo médio de espera',
},
{
label: 'TMR',
@ -241,7 +358,7 @@ export function AdminPage() {
{
label: 'Atendentes Ativos',
value: overview ? `${overview.activeAttendants} de ${overview.totalActiveUsers}` : '...',
detail: 'ativos no mes',
detail: 'ativos no mês',
},
];
@ -252,6 +369,10 @@ export function AdminPage() {
.toLowerCase()
.includes(search);
});
const availableSpecialtiesToAdd = areas.filter(
(area) => !editUserSpecialties.some((specialty) => specialty.id === area.id),
);
const isEditingAdmin = getProfileName(editUserProfileId) === 'Admin';
const channelDistributionData = overview
? [
@ -266,56 +387,12 @@ export function AdminPage() {
{
key: 'nome',
label: 'Usuario',
render: (row) => (
<div>
<strong style={{ display: 'block' }}>{row.nome}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
{row.email || 'Sem email'}
</span>
</div>
),
render: (row) => <strong>{row.nome}</strong>,
},
{
key: 'perfil',
label: 'Perfil',
render: (row) =>
profiles.length ? (
<select
value={row.perfilPrincipal?.id || ''}
onChange={(event) => handleAccessChange(row, 'perfil', event.target.value)}
style={selectStyle}
>
<option value="">Sem perfil</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.nome}
</option>
))}
</select>
) : (
<span>{row.perfilPrincipal?.nome || 'Sem perfil'}</span>
),
},
{
key: 'area',
label: 'Area',
render: (row) =>
areas.length ? (
<select
value={row.areaPrincipal?.id || ''}
onChange={(event) => handleAccessChange(row, 'area', event.target.value)}
style={selectStyle}
>
<option value="">Sem area</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
) : (
<span>{row.areaPrincipal?.nome || 'Sem area'}</span>
),
render: (row) => <span>{row.perfilPrincipal?.nome || 'Sem perfil'}</span>,
},
{
key: 'status',
@ -339,39 +416,70 @@ export function AdminPage() {
);
},
},
{
key: 'actions',
label: 'Ações',
render: (row) => (
<button
type="button"
onClick={() => openUserEditor(row)}
style={{
border: 'none',
borderRadius: 14,
padding: '0.7rem 0.9rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Editar
</button>
),
},
],
[areas, profiles],
[],
);
const areaColumns = useMemo(
() => [
{ key: 'nome', label: 'Area' },
{ key: 'nome', label: 'Especialidade' },
{
key: 'responsavel',
label: 'Responsavel',
render: (row) => (
<select
value={row.responsavel_usuario_id || ''}
onChange={(event) => handleAreaOwnerChange(row.id, event.target.value)}
style={selectStyle}
>
<option value="">Sem responsavel</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.nome}
</option>
))}
</select>
),
key: 'supervisores',
label: 'Supervisores',
render: (row) => {
const supervisors = Array.isArray(row.supervisores) ? row.supervisores : [];
return supervisors.length ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{supervisors.map((supervisor) => (
<span
key={supervisor.id}
style={{
borderRadius: 999,
padding: '0.25rem 0.55rem',
background: 'rgba(0, 164, 183, 0.1)',
color: 'var(--color-primary)',
fontWeight: 800,
fontSize: '0.82rem',
}}
>
{supervisor.nome}
</span>
))}
</div>
) : (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Sem supervisor</span>
);
},
},
{ key: 'members', label: 'Usuários' },
{ key: 'members', label: 'Usuarios' },
{
key: 'status',
label: 'Status',
render: (row) => (row.ativo ? 'Ativa' : 'Inativa'),
},
],
[users],
[],
);
const filteredRanking = selectedAreaFilter === 'all'
@ -380,10 +488,10 @@ export function AdminPage() {
const rankingColumns = [
{ key: 'name', label: 'Nome' },
{ key: 'area', label: 'Area' },
{ key: 'area', label: 'Especialidade' },
{ key: 'closed', label: 'Atendimentos finalizados' },
{ key: 'avgTime', label: 'Tempo medio' },
{ key: 'satisfaction', label: 'Satisfacao' },
{ key: 'avgTime', label: 'Tempo médio' },
{ key: 'satisfaction', label: 'Satisfação' },
];
function sendNotice() {
@ -454,9 +562,9 @@ export function AdminPage() {
<>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<label style={{ display: 'grid', gap: '0.35rem', minWidth: isMobile ? '100%' : 260 }}>
<span style={{ fontWeight: 700 }}>Filtro por area</span>
<span style={{ fontWeight: 700 }}>Filtro por especialidade</span>
<select value={selectedAreaFilter} onChange={(event) => setSelectedAreaFilter(event.target.value)} style={selectStyle}>
<option value="all">Todas as areas</option>
<option value="all">Todas as especialidades</option>
{areas.map((area) => (
<option key={area.id} value={area.nome}>{area.nome}</option>
))}
@ -467,10 +575,10 @@ export function AdminPage() {
<MetricGrid metrics={realMonthlyKpis} minCardWidth="160px" />
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? 'minmax(0, 1.85fr) minmax(300px, 1fr)' : '1fr', gap: '1rem' }}>
<DataPanel title="Atendimentos por dia" description="Volume diario do mes selecionado.">
<DataPanel title="Atendimentos por dia" description="Volume diário do mês selecionado.">
{renderLineChart()}
</DataPanel>
<DataPanel title="Distribuicao por canal" description="Participacao mensal por canal.">
<DataPanel title="Distribuição por canal" description="Participação mensal por canal.">
{renderDonutChart()}
</DataPanel>
</div>
@ -511,7 +619,7 @@ export function AdminPage() {
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(0, 1.2fr) minmax(320px, 0.8fr)' : '1fr',
gridTemplateColumns: '1fr',
gap: '1rem',
alignItems: 'start',
}}
@ -520,43 +628,207 @@ export function AdminPage() {
title="Usuarios e niveis de acesso"
description={
isLoadingAccess
? 'Carregando usuarios do banco...'
: accessError || 'Gerencie perfil e area principal dos usuarios autenticados.'
? 'Carregando usuários do banco...'
: accessError || 'Gerencie perfil e especialidade principal dos usuários autenticados.'
}
actionLabel="Adicionar usuario"
actionLabel="Adicionar usuário"
>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<input
type="search"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder="Buscar usuario por nome, email, perfil ou area"
placeholder="Buscar usuário por nome, email, perfil ou especialidade"
style={selectStyle}
/>
<div style={{ maxHeight: 470, overflowY: 'auto', paddingRight: '0.2rem' }}>
<ManagementTable columns={userColumns} rows={filteredUsers} getRowId={(row) => row.id} isMobile={isMobile} />
</div>
{editingUser ? (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 49, 80, 0.28)',
display: 'grid',
placeItems: 'center',
padding: '1rem',
zIndex: 30,
}}
>
<div
style={{
width: 'min(560px, 100%)',
background: '#fff',
borderRadius: 24,
boxShadow: 'var(--shadow-lg)',
padding: '1.25rem',
display: 'grid',
gap: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.2rem' }}>Editar acesso</h2>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
{editingUser.nome} · {editingUser.email || 'Sem email'}
</p>
</div>
<button
type="button"
onClick={closeUserEditor}
style={{
border: 'none',
background: 'transparent',
color: 'var(--color-text-soft)',
fontWeight: 800,
}}
>
Fechar
</button>
</div>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 800 }}>Perfil global</span>
<select
value={editUserProfileId}
onChange={(event) => handleEditProfileChange(event.target.value)}
style={selectStyle}
>
<option value="">Sem perfil</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.nome}
</option>
))}
</select>
</label>
{isEditingAdmin ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.85rem',
background: 'rgba(0, 164, 183, 0.08)',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Admin tem acesso global. Especialidades não se aplicam para este perfil.
</div>
) : (
<div style={{ display: 'grid', gap: '0.75rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto', gap: '0.65rem' }}>
<select
value={specialtyToAdd}
onChange={(event) => setSpecialtyToAdd(event.target.value)}
style={selectStyle}
>
<option value="">Selecionar especialidade</option>
{availableSpecialtiesToAdd.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
<button
type="button"
onClick={addSpecialtyToEdit}
disabled={!specialtyToAdd}
style={{
border: 'none',
borderRadius: 14,
padding: '0.75rem 0.95rem',
background: specialtyToAdd ? 'var(--color-primary)' : 'rgba(0, 49, 80, 0.12)',
color: specialtyToAdd ? '#fff' : 'var(--color-text-soft)',
fontWeight: 800,
}}
>
Adicionar
</button>
</div>
<div style={{ display: 'grid', gap: '0.55rem', maxHeight: 260, overflowY: 'auto', paddingRight: '0.2rem' }}>
{editUserSpecialties.length ? editUserSpecialties.map((specialty) => (
<div
key={specialty.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.75rem',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) 140px auto',
gap: '0.65rem',
alignItems: 'center',
}}
>
<strong>{specialty.nome}</strong>
<select
value={specialty.funcao || 'Agente'}
onChange={(event) => updateEditSpecialtyRole(specialty.id, event.target.value)}
style={compactSelectStyle}
>
<option value="Agente">Agente</option>
<option value="Supervisor">Supervisor</option>
</select>
<button
type="button"
onClick={() => removeSpecialtyFromEdit(specialty.id)}
style={{
border: 'none',
borderRadius: 12,
padding: '0.55rem 0.7rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Remover
</button>
</div>
)) : (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhuma especialidade selecionada.
</span>
)}
</div>
</div>
)}
<button
type="button"
onClick={submitUserEditor}
style={{
border: 'none',
borderRadius: 16,
padding: '0.9rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Salvar acesso
</button>
</div>
</div>
) : null}
</div>
</DataPanel>
<DataPanel title="Areas" description="Areas operacionais e seus responsaveis. Alterar o responsavel atribui perfil Supervisor a ele.">
<DataPanel title="Especialidades" description="Especialidades operacionais. Supervisores são definidos em Usuários e níveis de acesso.">
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(0, 1fr) auto', gap: '0.75rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto', gap: '0.75rem' }}>
<input
type="text"
value={newAreaName}
onChange={(event) => setNewAreaName(event.target.value)}
placeholder="Nome da nova area"
placeholder="Nome da nova especialidade"
style={selectStyle}
/>
<select value={newAreaOwnerId} onChange={(event) => setNewAreaOwnerId(event.target.value)} style={selectStyle}>
<option value="">Responsavel opcional</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.nome}
</option>
))}
</select>
<button
type="button"
onClick={handleCreateArea}
@ -583,7 +855,7 @@ export function AdminPage() {
return (
<DataPanel title={title} description={description}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 18, padding: '1rem', background: '#fff', color: 'var(--color-text-soft)', fontWeight: 700 }}>
Secao em preparacao.
Seção em preparação.
</div>
</DataPanel>
);
@ -591,7 +863,7 @@ export function AdminPage() {
const sectionContent = {
home: renderMonthlyHome(),
today: renderPlaceholder('Operação', 'Visão operacional diaria sera consolidada aqui.'),
today: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
'users-access': renderUsersAccess(),
templates: renderPlaceholder('Templates', 'Gestão de templates aprovados pela Meta.'),
knowledge: (
@ -599,17 +871,41 @@ export function AdminPage() {
<ManagementTable columns={contentColumns} rows={aiContentRows} getRowId={(row) => row.id} isMobile={isMobile} />
</DataPanel>
),
audit: renderPlaceholder('Auditoria', 'Eventos administrativos e alteracoes sensiveis.'),
audit: renderPlaceholder('Auditoria', 'Eventos administrativos e alterações sensíveis.'),
channels: renderPlaceholder('Canais', 'Status e configurações dos canais conectados.'),
attendance: (
<AdminAttendanceWorkspace
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isTablet={isTablet}
isMobile={isMobile}
/>
),
'mass-message': renderPlaceholder('Disparo em massa', 'Fluxo de disparos por templates aprovados.'),
contacts: renderPlaceholder('Contatos', 'Agenda geral de contatos.'),
settings: renderPlaceholder('Configurações', 'Preferencias e parametros do ambiente.'),
};
const pageTitle = activeAdminSection === 'home'
? 'Home do Admin'
: activeAdminSection === 'attendance'
? 'Atendimento'
: activeAdminSection === 'today'
? 'Operação'
: 'Painel administrativo';
const pageSubtitle = activeAdminSection === 'home'
? 'Visão mensal consolidada por especialidade, canal e atendente.'
: activeAdminSection === 'attendance'
? 'Home operacional do atendente dentro do painel administrativo.'
: activeAdminSection === 'today'
? 'Indicadores do dia, fila de espera e acompanhamento operacional do time.'
: 'Controle operacional e configurações administrativas.';
return (
<ManagementLayout
title={activeAdminSection === 'home' ? 'Home do Admin' : 'Painel administrativo'}
subtitle={activeAdminSection === 'home' ? 'Visão mensal consolidada por area, canal e atendente.' : 'Controle operacional e configuracoes administrativas.'}
title={pageTitle}
subtitle={pageSubtitle}
activeSection="admin"
profileLabel={userDisplay.name}
initials={userDisplay.initials}

View File

@ -1,323 +1,23 @@
import { useEffect, useState } from 'react';
import { DataPanel } from '../components/DataPanel';
import { ManagementLayout } from '../components/ManagementLayout';
import { ManagementTable } from '../components/ManagementTable';
import { MetricGrid } from '../components/MetricGrid';
import { areaRows, queueRows, supervisorMetrics } from '../services/managementMocks';
import { OperationalDashboard } from '../components/OperationalDashboard';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
import { API_BASE_URL } from '../../../shared/services/apiConfig';
const queueColumns = [
{ key: 'customer', label: 'Cliente' },
{ key: 'channel', label: 'Canal' },
{ key: 'area', label: 'Area' },
{ key: 'wait', label: 'Espera' },
{
key: 'priority',
label: 'Prioridade',
render: (row) => (
<span
style={{
width: 'fit-content',
borderRadius: 999,
padding: '0.25rem 0.6rem',
background: row.priority === 'Alta' ? 'rgba(181, 31, 31, 0.1)' : 'rgba(0, 49, 80, 0.08)',
color: row.priority === 'Alta' ? 'var(--color-secondary)' : 'var(--color-primary)',
fontWeight: 700,
}}
>
{row.priority}
</span>
),
},
];
const areaColumns = [
{ key: 'name', label: 'Area' },
{ key: 'owner', label: 'Responsavel' },
{ key: 'members', label: 'Usuarios' },
{ key: 'openTickets', label: 'Abertos' },
{ key: 'status', label: 'Status' },
];
export function SupervisorPage() {
const { isDesktop, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const [templates, setTemplates] = useState([]);
const [editingTemplate, setEditingTemplate] = useState(null);
const [editName, setEditName] = useState('');
const [editContent, setEditContent] = useState('');
const [saveStatus, setSaveStatus] = useState('');
const fetchTemplates = async () => {
try {
const res = await fetch(`${API_BASE_URL}/whatsapp/templates`);
if (res.ok) {
const data = await res.json();
setTemplates(data);
}
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetchTemplates();
}, []);
const handleEdit = (tpl) => {
setEditingTemplate(tpl);
setEditName(tpl.name);
setEditContent(tpl.content);
setSaveStatus('');
};
const handleSave = async (e) => {
e.preventDefault();
if (!editName || !editContent) return;
try {
const url = editingTemplate
? `${API_BASE_URL}/whatsapp/templates/update/${editingTemplate.id}`
: `${API_BASE_URL}/whatsapp/templates`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName, content: editContent }),
});
if (res.ok) {
setSaveStatus('Salvo com sucesso!');
setEditingTemplate(null);
setEditName('');
setEditContent('');
fetchTemplates();
setTimeout(() => setSaveStatus(''), 3000);
} else {
setSaveStatus('Erro ao salvar template.');
}
} catch (err) {
console.error(err);
setSaveStatus('Erro ao salvar template.');
}
};
return (
<ManagementLayout
title="Painel do supervisor"
subtitle="Acompanhamento operacional das filas, areas e distribuicao de atendimento."
subtitle="Indicadores do dia, fila de espera e acompanhamento operacional do time."
activeSection="supervisor"
profileLabel={userDisplay.name}
initials={userDisplay.initials}
isDesktop={isDesktop}
isMobile={isMobile}
>
<MetricGrid metrics={supervisorMetrics} />
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(0, 1.35fr) minmax(320px, 0.85fr)' : '1fr',
gap: '1rem',
alignItems: 'start',
}}
>
<DataPanel
title="Fila em tempo real"
description="Mock da visao que depois sera alimentada pelos atendimentos reais."
actionLabel="Redistribuir fila"
>
<ManagementTable
columns={queueColumns}
rows={queueRows}
getRowId={(row) => row.id}
isMobile={isMobile}
/>
</DataPanel>
<DataPanel
title="Areas supervisionadas"
description="Resumo operacional por area."
actionLabel="Ver detalhes"
>
<ManagementTable
columns={areaColumns}
rows={areaRows}
getRowId={(row) => row.id}
isMobile={isMobile}
/>
</DataPanel>
</div>
<div style={{ marginTop: '1.5rem' }}>
<DataPanel
title="Homologador de Templates WhatsApp (Meta)"
description="Gerencie os modelos de primeiro contato pré-aprovados pela Meta para uso dos atendentes."
>
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '1.2fr 0.8fr' : '1fr', gap: '1.5rem', padding: '0.5rem 0' }}>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{templates.map((tpl) => (
<div
key={tpl.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '1rem',
background: '#fff',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: '0.6rem',
boxShadow: 'var(--shadow-sm)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '0.96rem', color: 'var(--color-primary)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>
{tpl.name}
</strong>
<span style={{ fontSize: '0.75rem', fontWeight: 700, padding: '0.2rem 0.5rem', borderRadius: '999px', background: 'rgba(34, 197, 94, 0.1)', color: '#16a34a' }}>
Homologado Meta
</span>
</div>
<p style={{ margin: 0, color: 'var(--color-text-soft)', fontSize: '0.88rem', lineHeight: 1.5 }}>
{tpl.content}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '0.25rem' }}>
<button
type="button"
onClick={() => handleEdit(tpl)}
style={{
border: 'none',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
padding: '0.45rem 0.85rem',
borderRadius: '10px',
fontSize: '0.82rem',
fontWeight: 700,
cursor: 'pointer',
transition: 'background 0.2s',
}}
>
Editar Modelo
</button>
</div>
</div>
))}
</div>
<form
onSubmit={handleSave}
style={{
border: '1px solid var(--color-border)',
borderRadius: '20px',
padding: '1.25rem',
background: '#f8fafc',
display: 'grid',
gap: '1rem',
height: 'fit-content',
}}
>
<strong style={{ fontSize: '1.05rem', display: 'block', borderBottom: '1px solid var(--color-border)', paddingBottom: '0.5rem', color: 'var(--color-text)' }}>
{editingTemplate ? `Editar Template #${editingTemplate.id}` : 'Criar Novo Template'}
</strong>
{saveStatus && (
<div style={{
padding: '0.65rem 0.85rem',
borderRadius: '12px',
background: saveStatus.includes('sucesso') ? 'rgba(34, 197, 94, 0.12)' : 'rgba(239, 68, 68, 0.12)',
color: saveStatus.includes('sucesso') ? '#16a34a' : '#ef4444',
fontWeight: 700,
fontSize: '0.85rem',
}}>
{saveStatus}
</div>
)}
<label style={{ display: 'grid', gap: '0.35rem', color: 'var(--color-text)' }}>
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>Identificador Único (Nome)</span>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="ex: aviso_fatura"
style={{
border: '1px solid var(--color-border)',
borderRadius: '12px',
padding: '0.75rem 0.85rem',
background: '#fff',
outline: 'none',
fontSize: '0.88rem',
color: 'var(--color-text)',
}}
required
/>
</label>
<label style={{ display: 'grid', gap: '0.35rem', color: 'var(--color-text)' }}>
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>Mensagem do Template</span>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
placeholder="Use placeholders como {nome}, {data} ou {protocolo}..."
rows={4}
style={{
border: '1px solid var(--color-border)',
borderRadius: '12px',
padding: '0.75rem 0.85rem',
background: '#fff',
outline: 'none',
fontSize: '0.88rem',
resize: 'none',
lineHeight: 1.5,
color: 'var(--color-text)',
}}
required
/>
</label>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
{editingTemplate && (
<button
type="button"
onClick={() => {
setEditingTemplate(null);
setEditName('');
setEditContent('');
}}
style={{
border: 'none',
background: 'rgba(239, 68, 68, 0.08)',
color: '#ef4444',
padding: '0.65rem 1rem',
borderRadius: '12px',
fontSize: '0.85rem',
fontWeight: 700,
cursor: 'pointer',
}}
>
Cancelar
</button>
)}
<button
type="submit"
style={{
border: 'none',
background: 'var(--color-primary)',
color: '#fff',
padding: '0.65rem 1rem',
borderRadius: '12px',
fontSize: '0.85rem',
fontWeight: 700,
cursor: 'pointer',
}}
>
{editingTemplate ? 'Atualizar Modelo' : 'Criar Modelo'}
</button>
</div>
</form>
</div>
</DataPanel>
</div>
<OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />
</ManagementLayout>
);
}

View File

@ -2,12 +2,12 @@ export const supervisorMetrics = [
{ label: 'Atendimentos abertos', value: '42', detail: '12 aguardando agente' },
{ label: 'SLA em risco', value: '7', detail: 'Financeiro concentra 4 casos' },
{ label: 'Agentes online', value: '18', detail: '3 em pausa operacional' },
{ label: 'Transferencias hoje', value: '23', detail: 'Tempo medio 4m 20s' },
{ label: 'Transferências hoje', value: '23', detail: 'Tempo médio 4m 20s' },
];
export const adminMetrics = [
{ label: 'Usuários ativos', value: '64', detail: '8 supervisores configurados' },
{ label: 'Áreas cadastradas', value: '3', detail: 'Suporte, Financeiro e Comercial' },
{ label: 'Especialidades cadastradas', value: '3', detail: 'Suporte, Financeiro e Comercial' },
{ label: 'Conteúdos IA', value: '28', detail: '6 aguardando revisão' },
{ label: 'Canais conectados', value: '1', detail: 'WhatsApp em homologação' },
];
@ -100,7 +100,7 @@ export const aiContentRows = [
id: 'c3',
title: 'Argumentario de proposta comercial',
area: 'Comercial',
status: 'Revisao',
status: 'Revisão',
updatedAt: 'Segunda',
},
];