feat(secretaria): agente/cérebro por número na aba Números #2
@@ -310,6 +310,36 @@ export function createAgendaBridge(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /pedido ──────────────────────────────────────────────────────────────
|
||||
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
|
||||
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
|
||||
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
|
||||
// nome_cliente, telefone?, procedimento? }
|
||||
router.post('/pedido', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
|
||||
try {
|
||||
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
|
||||
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
|
||||
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
|
||||
// Resolve o dentista pelo nome (opcional).
|
||||
let dentistaId = null;
|
||||
if (b.dentista_nome) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
|
||||
[b.clinica_id, b.dentista_nome]);
|
||||
dentistaId = rows[0]?.id || null;
|
||||
}
|
||||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
|
||||
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
|
||||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
|
||||
res.json({ ok: true, pedido_id: id });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /book ────────────────────────────────────────────────────────────────
|
||||
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
|
||||
router.post('/book', async (req, res) => {
|
||||
|
||||
@@ -84,7 +84,9 @@ export function createAgendaConfig(pool) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim, ativo
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim,
|
||||
to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex,
|
||||
to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo
|
||||
FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`,
|
||||
[req.params.dentistaId]);
|
||||
res.json({ horarios: rows });
|
||||
@@ -109,9 +111,13 @@ export function createAgendaConfig(pool) {
|
||||
for (const h of linhas) {
|
||||
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
|
||||
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
|
||||
// Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim.
|
||||
let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex);
|
||||
if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo
|
||||
if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde
|
||||
await client.query(
|
||||
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim]);
|
||||
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
@@ -210,6 +216,72 @@ export function createAgendaConfig(pool) {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
|
||||
async function ehMembro(clinicaId, userId) {
|
||||
if (await isDono(pool, clinicaId, userId)) return true;
|
||||
try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
router.get('/clinica/:clinicaId/pedidos', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const status = req.query.status ? String(req.query.status) : 'pendente';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
|
||||
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
|
||||
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
|
||||
WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`,
|
||||
[req.params.clinicaId, status]);
|
||||
res.json({ pedidos: rows, total: rows.length });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Confirma → cria o agendamento real e marca o pedido como confirmado.
|
||||
// Body: { dentista_id? (se o pedido não tinha), duracao_min? }
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]);
|
||||
const ped = rows[0];
|
||||
if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); }
|
||||
if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); }
|
||||
const dentistaId = req.body?.dentista_id || ped.dentista_id;
|
||||
if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); }
|
||||
const dur = Math.max(10, Number(req.body?.duracao_min) || 30);
|
||||
const hi = String(ped.hora_inicio).slice(0, 5);
|
||||
const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`;
|
||||
const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur);
|
||||
const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`;
|
||||
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await client.query(
|
||||
`INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento,
|
||||
start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`,
|
||||
[agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId,
|
||||
ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]);
|
||||
await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`,
|
||||
[agId, req.nwUser.userId, req.params.id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, agendamento_id: agId });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
try {
|
||||
await pool.query(`UPDATE agenda_pedidos SET status='recusado', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
|
||||
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o
|
||||
// dentista achar seu registro). Retorna também se o usuário logado é o dentista.
|
||||
router.get('/clinica/:clinicaId/dentistas', async (req, res) => {
|
||||
|
||||
@@ -49,6 +49,36 @@ export async function ensureAgendaSchema(pool) {
|
||||
// ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado.
|
||||
// Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo.
|
||||
await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`);
|
||||
|
||||
// ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em
|
||||
// [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação
|
||||
// humana. Nulo = sem zona cinza (só a janela livre).
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`);
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`);
|
||||
|
||||
// ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana.
|
||||
// A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma
|
||||
// (vira agendamento) ou recusa.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_pedidos (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
dentista_id varchar,
|
||||
sala_id varchar,
|
||||
data date NOT NULL,
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
hora_fim time without time zone,
|
||||
paciente_nome varchar,
|
||||
paciente_telefone varchar,
|
||||
procedimento varchar,
|
||||
origem varchar DEFAULT 'secretaria_ia',
|
||||
status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado
|
||||
agendamento_id varchar, -- preenchido ao confirmar
|
||||
created_at timestamptz DEFAULT now(),
|
||||
resolved_at timestamptz,
|
||||
resolved_by varchar
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o
|
||||
// ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono).
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User } from 'lucide-react';
|
||||
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
@@ -20,13 +20,18 @@ async function req(path: string, opts: RequestInit = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string }
|
||||
interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null }
|
||||
interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean }
|
||||
|
||||
// Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista.
|
||||
const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean }> = ({ faixas, onChange, disabled }) => {
|
||||
const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => {
|
||||
const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d);
|
||||
const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]);
|
||||
// Copia as faixas do dia `d` para TODOS os outros dias.
|
||||
const duplicarParaTodos = (d: number) => {
|
||||
const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim }));
|
||||
onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f }))));
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{DIAS.map((nome, d) => {
|
||||
@@ -36,24 +41,45 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
|
||||
<div className="w-24 shrink-0 pt-2 text-xs font-black text-gray-700 uppercase tracking-tight">{nome}</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{fs.length === 0 && <span className="text-[11px] text-gray-400 italic">Fechado</span>}
|
||||
{fs.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio}
|
||||
onChange={(e) => { const n = [...fs]; n[i] = { ...f, hora_inicio: e.target.value }; setDia(d, n); }}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
<span className="text-gray-400 text-xs font-bold">até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim}
|
||||
onChange={(e) => { const n = [...fs]; n[i] = { ...f, hora_fim: e.target.value }; setDia(d, n); }}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
{!disabled && <button onClick={() => setDia(d, fs.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={15} /></button>}
|
||||
{fs.map((f, i) => {
|
||||
const upd = (patch: Partial<Faixa>) => { const n = [...fs]; n[i] = { ...f, ...patch }; setDia(d, n); };
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio} onChange={(e) => upd({ hora_inicio: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
<span className="text-gray-400 text-xs font-bold">até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim} onChange={(e) => upd({ hora_fim: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
{withFlex && <span className="text-[9px] font-black text-teal-500 uppercase tracking-wide">IA agenda livre</span>}
|
||||
{!disabled && <button onClick={() => setDia(d, fs.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={15} /></button>}
|
||||
</div>
|
||||
{withFlex && (
|
||||
<div className="flex items-center gap-2 flex-wrap pl-1 text-[11px] text-gray-500">
|
||||
<span className="font-bold text-amber-600">Aceito confirmar também:</span>
|
||||
<span>antes, a partir de</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio_flex ?? ''} onChange={(e) => upd({ hora_inicio_flex: e.target.value || null })}
|
||||
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
|
||||
<span>e depois, até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim_flex ?? ''} onChange={(e) => upd({ hora_fim_flex: e.target.value || null })}
|
||||
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{!disabled && (
|
||||
<button onClick={() => setDia(d, [...fs, { dia_semana: d, hora_inicio: '08:00', hora_fim: '18:00' }])}
|
||||
className="text-[11px] font-black text-teal-600 hover:text-teal-800 uppercase tracking-wide flex items-center gap-1">
|
||||
<Plus size={13} /> {fs.length ? 'Outra faixa' : 'Abrir neste dia'}
|
||||
</button>
|
||||
)}
|
||||
{!disabled && fs.length > 0 && (
|
||||
<button onClick={() => duplicarParaTodos(d)} title="Aplicar este mesmo horário a todos os dias da semana"
|
||||
className="text-[10px] font-black text-gray-400 hover:text-teal-600 uppercase tracking-wide flex items-center gap-1">
|
||||
<Copy size={11} /> Copiar p/ todos os dias
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,7 +138,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
useEffect(() => {
|
||||
if (!dentSel) { setDentFaixas([]); setFolgas([]); return; }
|
||||
req(`/dentista/${dentSel}/horarios`).then((h) =>
|
||||
setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim })))
|
||||
setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim, hora_inicio_flex: r.hora_inicio_flex, hora_fim_flex: r.hora_fim_flex })))
|
||||
).catch((e) => flash(setErro, e.message));
|
||||
carregarFolgas(dentSel);
|
||||
}, [dentSel, carregarFolgas]);
|
||||
@@ -231,8 +257,8 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
{dentSel ? <GradeSemanal faixas={dentFaixas} onChange={setDentFaixas} /> : <p className="text-xs text-gray-400">Nenhum dentista.</p>}
|
||||
<p className="text-[11px] text-gray-400 italic">Sem horário próprio, o dentista atende sempre que a clínica está aberta. Cada dentista (ou o dono) edita os seus.</p>
|
||||
{dentSel ? <GradeSemanal faixas={dentFaixas} onChange={setDentFaixas} withFlex /> : <p className="text-xs text-gray-400">Nenhum dentista.</p>}
|
||||
<p className="text-[11px] text-gray-400 italic">A IA agenda LIVRE na janela principal. Na janela "aceito confirmar" (mais cedo/mais tarde), ela não confirma sozinha — cria um pedido para a secretária humana decidir.</p>
|
||||
|
||||
{dentSel && (
|
||||
<section className="pt-4 border-t border-gray-100">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Fila da secretária humana: pedidos de horário da ZONA CINZA que a Secretária IA
|
||||
// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou
|
||||
// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, CalendarClock, Check, Ban, Loader2, Phone, User } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
|
||||
const t = await res.text(); const d = t ? JSON.parse(t) : {};
|
||||
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
const dm = (s: string) => s ? s.split('-').reverse().join('/') : s;
|
||||
|
||||
export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => {
|
||||
const [pedidos, setPedidos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
setLoading(true); setErro(null);
|
||||
try {
|
||||
const p = await req(`/clinica/${clinicaId}/pedidos?status=pendente`);
|
||||
setPedidos(p.pedidos || []);
|
||||
const d = await req(`/clinica/${clinicaId}/dentistas`);
|
||||
setDentistas(d.dentistas || []);
|
||||
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
|
||||
|
||||
const confirmar = async (p: any) => {
|
||||
const dentistaId = p.dentista_id || sel[p.id];
|
||||
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const recusar = async (p: any) => {
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[90vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-amber-500 rounded-2xl shadow-lg shadow-amber-200"><CalendarClock size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pedidos aguardando confirmação</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Horários fora do padrão que a Secretária IA encaminhou</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{erro && <div className="px-8 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
|
||||
) : pedidos.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p>
|
||||
) : pedidos.map((p) => (
|
||||
<div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-black text-gray-800">
|
||||
{dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `–${p.hora_fim}` : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
|
||||
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
|
||||
{p.procedimento && <div className="text-[11px] text-gray-500">{p.procedimento}</div>}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{p.dentista_id ? (
|
||||
<span className="text-[11px] font-bold text-teal-600">{p.dentista_nome}</span>
|
||||
) : (
|
||||
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none">
|
||||
<option value="">Escolher dentista…</option>
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => confirmar(p)} disabled={busy === p.id}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<Ban size={13} /> Recusar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
|
||||
import { HorariosConfig } from '../components/HorariosConfig.tsx';
|
||||
import { PedidosPendentes } from '../components/PedidosPendentes.tsx';
|
||||
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
|
||||
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
|
||||
import { AgendaPresence } from '../components/AgendaPresence.tsx';
|
||||
@@ -352,6 +353,25 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [showMeusHorarios, setShowMeusHorarios] = useState(false);
|
||||
const [showPedidos, setShowPedidos] = useState(false);
|
||||
const [pedidosCount, setPedidosCount] = useState(0);
|
||||
// Pedidos da zona cinza aguardando a secretária humana — badge + poll a cada 30 min.
|
||||
useEffect(() => {
|
||||
const cid = (HybridBackend.getActiveWorkspace() as any)?.id;
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
if (!cid || !['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role)) return;
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const r = await fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (r.ok && alive) { const j = await r.json(); setPedidosCount(j.total || (j.pedidos || []).length || 0); }
|
||||
} catch { /* silencioso */ }
|
||||
};
|
||||
poll();
|
||||
const t = setInterval(poll, 30 * 60 * 1000); // 30 min
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
}, []);
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
@@ -653,6 +673,14 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
<span className="lg:hidden text-sm font-bold">Importar do Google</span>
|
||||
</button>
|
||||
)}
|
||||
{['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && (
|
||||
<button onClick={() => { setShowPedidos(true); onCloseControls?.(); }} title="Pedidos de horário aguardando confirmação (encaminhados pela Secretária IA)"
|
||||
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
<CalendarClock size={20} className="shrink-0" />
|
||||
<span className="lg:hidden text-sm font-bold">Pedidos pendentes</span>
|
||||
{pedidosCount > 0 && <span className="absolute -top-1 -right-1 lg:top-0 lg:right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
|
||||
<button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda"
|
||||
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
@@ -888,6 +916,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
/>
|
||||
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
||||
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
|
||||
<PedidosPendentes isOpen={showPedidos} onClose={() => setShowPedidos(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
|
||||
onChange={() => { const cid = (HybridBackend.getActiveWorkspace() as any)?.id; const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (cid) fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(r => r.ok ? r.json() : { total: 0 }).then(j => setPedidosCount(j.total || 0)).catch(() => {}); }} />
|
||||
|
||||
{showInteresses && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}>
|
||||
|
||||
Reference in New Issue
Block a user