Files
scoreodonto.com/frontend/views/SalaHomeView.tsx
VPS 4 Builder 983582f066 feat(salas): acesso operacional do funcionário à sala + bloqueio p/ manutenção
Funcionário/membro com vínculo na clínica dona da sala passa a ter a sala como
workspace de acesso OPERACIONAL (papel_sala='operador'): vê a agenda da sala e
bloqueia horário para manutenção, sem locação nem financeiro. O Painel da Sala
detecta o modo operador e esconde recebíveis/relatório/baixa, mostrando só a
agenda + ação de bloqueio.

Bloqueio de manutenção: reserva tipo='manutencao' (coluna nova, default
'locacao'), valor 0, nasce 'confirmada' (sem aprovação) e NÃO gera recebível.
Reaproveita o anti-conflito de horário da sala (409 em sobreposição) e o
cancelamento por solicitante/unidade. A agenda renderiza o bloqueio em cinza.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:23:36 +02:00

336 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays, Wrench, X } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
// ─── Agenda semanal visual da sala ────────────────────────────────────────────
const DIAS = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
const H_INI = 7, H_FIM = 22, ROW_H = 40; // 07h22h, 40px/hora
const segOf = (d: Date) => { const x = new Date(d); const dow = (x.getDay() + 6) % 7; x.setHours(0, 0, 0, 0); x.setDate(x.getDate() - dow); return x; };
const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const AgendaSala: React.FC<{ reservas: any[]; onBloquear?: () => void }> = ({ reservas, onBloquear }) => {
const [weekStart, setWeekStart] = useState(() => segOf(new Date()));
const dias = Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart); d.setDate(d.getDate() + i); return d; });
const ativas = reservas.filter(r => r.status === 'pendente' || r.status === 'confirmada');
const fimSemana = new Date(weekStart); fimSemana.setDate(fimSemana.getDate() + 6);
const label = `${weekStart.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' })} ${fimSemana.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' })}`;
const blocosDoDia = (dia: Date) => ativas.map(r => {
const ini = new Date(r.inicio), fim = new Date(r.fim);
if (!sameDay(ini, dia)) return null;
const top = Math.max(0, (ini.getHours() + ini.getMinutes() / 60 - H_INI) * ROW_H);
const height = Math.max(16, ((fim.getTime() - ini.getTime()) / 3600000) * ROW_H);
const manut = r.tipo === 'manutencao';
const conf = r.status === 'confirmada';
const cls = manut ? 'bg-gray-200 border-gray-400 text-gray-700'
: conf ? 'bg-teal-100 border-teal-300 text-teal-800'
: 'bg-amber-100 border-amber-300 text-amber-800';
const legenda = manut ? (r.motivo || 'Manutenção') : (r.solicitante_nome || r.clinica_nome || 'Reserva');
return (
<div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${cls}`}
style={{ top, height }} title={`${legenda} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}>
<p className="text-[9px] font-black leading-tight truncate">{ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</p>
<p className="text-[9px] font-bold leading-tight truncate uppercase">{legenda}</p>
</div>
);
});
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between gap-3">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /> Agenda da sala</h3>
<div className="flex items-center gap-2">
{onBloquear && (
<button onClick={onBloquear} className="flex items-center gap-1.5 bg-gray-700 text-white px-3 py-1.5 rounded-lg font-black text-[10px] uppercase hover:bg-gray-800 transition-colors">
<Wrench size={12} /> Bloquear manutenção
</button>
)}
<span className="text-[11px] font-black text-gray-500 uppercase">{label}</span>
<button onClick={() => setWeekStart(d => { const x = new Date(d); x.setDate(x.getDate() - 7); return x; })} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><ChevronLeft size={16} /></button>
<button onClick={() => setWeekStart(segOf(new Date()))} className="text-[10px] font-black uppercase text-teal-600 hover:bg-teal-50 px-2 py-1.5 rounded-lg">Hoje</button>
<button onClick={() => setWeekStart(d => { const x = new Date(d); x.setDate(x.getDate() + 7); return x; })} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><ChevronRight size={16} /></button>
</div>
</div>
<div className="overflow-x-auto">
<div className="min-w-[680px]">
{/* Cabeçalho dos dias */}
<div className="grid" style={{ gridTemplateColumns: '44px repeat(7, 1fr)' }}>
<div className="border-b border-gray-100" />
{dias.map((d, i) => {
const hoje = sameDay(d, new Date());
return (
<div key={i} className={`text-center py-2 border-b border-l border-gray-100 ${hoje ? 'bg-teal-50' : ''}`}>
<p className="text-[10px] font-black text-gray-400 uppercase">{DIAS[i]}</p>
<p className={`text-sm font-black ${hoje ? 'text-teal-700' : 'text-gray-700'}`}>{d.getDate()}</p>
</div>
);
})}
</div>
{/* Grade de horas */}
<div className="grid" style={{ gridTemplateColumns: '44px repeat(7, 1fr)' }}>
{/* coluna de horas */}
<div className="relative" style={{ height: (H_FIM - H_INI) * ROW_H }}>
{Array.from({ length: H_FIM - H_INI }, (_, h) => (
<div key={h} className="absolute right-1 text-[9px] font-bold text-gray-300" style={{ top: h * ROW_H - 6 }}>{String(H_INI + h).padStart(2, '0')}h</div>
))}
</div>
{dias.map((d, i) => (
<div key={i} className="relative border-l border-gray-100" style={{ height: (H_FIM - H_INI) * ROW_H }}>
{Array.from({ length: H_FIM - H_INI }, (_, h) => <div key={h} className="absolute left-0 right-0 border-b border-gray-50" style={{ top: (h + 1) * ROW_H }} />)}
{blocosDoDia(d)}
</div>
))}
</div>
</div>
</div>
<div className="px-6 py-2 flex gap-4 text-[10px] font-bold text-gray-400 uppercase border-t border-gray-50">
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-teal-200 border border-teal-300" /> Confirmada</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-amber-200 border border-amber-300" /> Pendente</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-gray-200 border border-gray-400" /> Manutenção</span>
</div>
</div>
);
};
// Modal: bloquear a sala para manutenção (data + faixa de horário + motivo).
const BloqueioModal: React.FC<{ onClose: () => void; onConfirm: (inicio: string, fim: string, motivo: string) => Promise<void> }> = ({ onClose, onConfirm }) => {
const toast = useToast();
const hojeYmd = new Date().toISOString().split('T')[0];
const [data, setData] = useState(hojeYmd);
const [hIni, setHIni] = useState('08:00');
const [hFim, setHFim] = useState('09:00');
const [motivo, setMotivo] = useState('');
const [saving, setSaving] = useState(false);
const salvar = async () => {
if (!data || !hIni || !hFim) { toast.error('PREENCHA DATA E HORÁRIOS.'); return; }
const inicio = new Date(`${data}T${hIni}`);
const fim = new Date(`${data}T${hFim}`);
if (fim <= inicio) { toast.error('O FIM DEVE SER DEPOIS DO INÍCIO.'); return; }
setSaving(true);
try { await onConfirm(inicio.toISOString(), fim.toISOString(), motivo.trim()); }
catch (e: any) { toast.error((e?.message || 'ERRO AO BLOQUEAR').toUpperCase()); }
finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="font-black text-sm uppercase text-gray-800 flex items-center gap-2"><Wrench size={16} className="text-gray-600" /> Bloquear p/ manutenção</h3>
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Data</label>
<input type="date" value={data} onChange={e => setData(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Início</label>
<input type="time" value={hIni} onChange={e => setHIni(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Fim</label>
<input type="time" value={hFim} onChange={e => setHFim(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo (opcional)</label>
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Ex.: limpeza, conserto do equipo..." className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm text-gray-700 outline-none focus:border-teal-400" />
</div>
<button onClick={salvar} disabled={saving} className="w-full bg-gray-700 text-white py-3 rounded-xl font-black text-sm uppercase hover:bg-gray-800 disabled:opacity-50 flex items-center justify-center gap-2">
{saving ? <RefreshCw size={16} className="animate-spin" /> : <Wrench size={16} />} Bloquear sala
</button>
</div>
</div>
</div>
);
};
// Painel da SALA: aparece quando o workspace ativo é uma sala (tipo='sala').
export const SalaHomeView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
const ws: any = HybridBackend.getActiveWorkspace();
// Operador (ex.: recepção): só vê a AGENDA e bloqueia manutenção — sem locação/financeiro.
const isOperador = ws?.papel_sala === 'operador';
const [lancamentos, setLancamentos] = useState<any[]>([]);
const [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] });
const [loading, setLoading] = useState(false);
const [bloqueioOpen, setBloqueioOpen] = useState(false);
// Relatório por sala (período)
const hoje = new Date();
const [inicio, setInicio] = useState(new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().split('T')[0]);
const [fim, setFim] = useState(hoje.toISOString().split('T')[0]);
const [rel, setRel] = useState<any | null>(null);
const [relLoading, setRelLoading] = useState(false);
const carregar = useCallback(async () => {
setLoading(true);
try {
if (isOperador) {
// Operador não tem acesso ao financeiro/recebíveis: carrega só a agenda da sala.
const rs = await HybridBackend.getSalaReservas(ws.id);
setLancamentos([]);
setReservas({ feitas: [], recebidas: (Array.isArray(rs) ? rs : []).map((r: any) => ({ ...r, sala_id: ws.id })) });
} else {
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
setLancamentos(Array.isArray(fin) ? fin : []);
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
}
} catch { /* silent */ } finally { setLoading(false); }
}, [isOperador, ws?.id]);
useEffect(() => { carregar(); }, [carregar]);
const carregarRel = useCallback(() => {
if (isOperador) { setRel(null); return; }
setRelLoading(true);
HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false));
}, [inicio, fim, isOperador]);
useEffect(() => { carregarRel(); }, [carregarRel]);
const recebido = lancamentos.filter(l => l.status === 'Pago').reduce((s, l) => s + Number(l.valor), 0);
const aReceber = lancamentos.filter(l => l.status !== 'Pago').reduce((s, l) => s + Number(l.valor), 0);
const reservasDaSala = reservas.recebidas.filter(r => r.sala_id === ws?.id);
const pendentes = reservasDaSala.filter(r => r.status === 'pendente').length;
const confirmadas = reservasDaSala.filter(r => r.status === 'confirmada').length;
const bloquearManutencao = async (inicio: string, fim: string, motivo: string) => {
await HybridBackend.criarManutencaoSala(ws.id, { inicio, fim, motivo });
toast.success('SALA BLOQUEADA PARA MANUTENÇÃO.');
setBloqueioOpen(false);
carregar();
};
const darBaixa = async (l: any) => {
if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return;
try {
await HybridBackend.updateFinanceiro({ ...l, status: 'Pago', pago_em: new Date().toISOString().split('T')[0] });
toast.success('RECEBIMENTO CONFIRMADO.');
carregar();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
</div>
);
return (
<div className="space-y-6 pb-10">
<PageHeader title="PAINEL DA SALA">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
</button>
</PageHeader>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm px-6 py-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#0d9488' }}><DoorOpen size={20} /></div>
<div className="min-w-0">
<p className="font-black text-gray-900 uppercase truncate">{ws?.nome || 'Sala'}</p>
{(ws?.cidade || ws?.estado) && <p className="text-[11px] text-gray-400 font-bold uppercase flex items-center gap-1"><MapPin size={11} /> {ws?.cidade}{ws?.estado ? `/${ws.estado}` : ''}</p>}
</div>
</div>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
<div className={`grid grid-cols-2 ${isOperador ? 'lg:grid-cols-2' : 'lg:grid-cols-4'} gap-4`}>
{!isOperador && <KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />}
{!isOperador && <KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />}
<KPI titulo="Reservas pendentes" valor={String(pendentes)} cor="text-red-500" Icon={Inbox} sub="Aguardando resposta" />
<KPI titulo="Reservas confirmadas" valor={String(confirmadas)} cor="text-teal-600" Icon={CalendarClock} sub="Nesta sala" />
</div>
<AgendaSala reservas={reservasDaSala} onBloquear={() => setBloqueioOpen(true)} />
{!isOperador && (<>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
<h3 className="text-sm font-black text-gray-800 uppercase">Recebíveis de locação</h3>
<span className="text-[10px] font-bold text-gray-400 uppercase">{lancamentos.length} lançamento(s)</span>
</div>
{lancamentos.length === 0 ? (
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebível ainda. Confirme reservas para gerar receita.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Descrição', 'Locatário', 'Venc.', 'Status', 'Valor', ''].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{lancamentos.map(l => (
<tr key={l.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-bold text-gray-700">{l.descricao}</td>
<td className="px-4 py-3 text-gray-500 uppercase">{l.pacientenome || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(l.datavencimento)}</td>
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase ${l.status === 'Pago' ? 'bg-green-100 text-green-700' : l.status === 'Atrasado' ? 'bg-red-100 text-red-600' : 'bg-amber-100 text-amber-700'}`}>{l.status}</span></td>
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(l.valor)}</td>
<td className="px-4 py-3 text-right">
{l.status !== 'Pago' && <button onClick={() => darBaixa(l)} title="MARCAR RECEBIDO" className="p-1.5 text-green-600 hover:bg-green-50 rounded-lg transition-colors"><CheckCircle size={16} /></button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Relatório por sala (faturamento + ocupação no período) */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex flex-wrap items-end justify-between gap-3">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><BarChart3 size={16} className="text-teal-600" /> Relatório por sala</h3>
<div className="flex items-end gap-2">
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Início</label><input type="date" value={inicio} onChange={e => setInicio(e.target.value)} className="bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs font-bold text-gray-700 outline-none focus:border-teal-400" /></div>
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Fim</label><input type="date" value={fim} onChange={e => setFim(e.target.value)} className="bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs font-bold text-gray-700 outline-none focus:border-teal-400" /></div>
<button onClick={carregarRel} className="bg-gray-100 text-gray-600 px-3 py-1.5 rounded-lg font-black text-[10px] uppercase hover:bg-gray-200 flex items-center gap-1.5"><Filter size={12} /> Filtrar</button>
</div>
</div>
{relLoading ? <div className="flex justify-center py-10"><RefreshCw className="animate-spin text-teal-500" size={22} /></div> : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Sala', 'Reservas', 'Horas', 'Recebido', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{(rel?.salas || []).map((s: any) => (
<tr key={s.id} className={`hover:bg-gray-50/50 ${s.id === ws?.id ? 'bg-teal-50/40' : ''}`}>
<td className="px-4 py-3 font-black text-gray-700 uppercase">{s.nome}{s.id === ws?.id && <span className="ml-2 text-[8px] font-black text-teal-600">(ATUAL)</span>}</td>
<td className="px-4 py-3 text-gray-600">{s.reservas}</td>
<td className="px-4 py-3 text-gray-600">{Number(s.horas).toLocaleString('pt-BR', { maximumFractionDigits: 1 })}h</td>
<td className="px-4 py-3 font-bold text-green-600">{fmtBRL(s.recebido)}</td>
<td className="px-4 py-3 font-bold text-amber-600">{fmtBRL(s.a_receber)}</td>
</tr>
))}
{(rel?.salas || []).length === 0 && <tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400 text-sm">Sem dados no período.</td></tr>}
</tbody>
{(rel?.salas || []).length > 0 && (
<tfoot className="bg-gray-50/60 border-t-2 border-gray-100">
<tr className="font-black text-gray-800">
<td className="px-4 py-3 uppercase text-[10px]">Total</td>
<td className="px-4 py-3">{rel.total_reservas}</td>
<td className="px-4 py-3">{Number(rel.total_horas).toLocaleString('pt-BR', { maximumFractionDigits: 1 })}h</td>
<td className="px-4 py-3 text-green-700">{fmtBRL(rel.total_recebido)}</td>
<td className="px-4 py-3 text-amber-700">{fmtBRL(rel.total_a_receber)}</td>
</tr>
</tfoot>
)}
</table>
</div>
)}
</div>
</>)}
</>
)}
{bloqueioOpen && <BloqueioModal onClose={() => setBloqueioOpen(false)} onConfirm={bloquearManutencao} />}
</div>
);
};