diff --git a/backend/server.js b/backend/server.js index 4c2f4e6..cfaeffd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -518,7 +518,20 @@ async function listarWorkspaces(userId, userRole) { id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'donosala', tipo: 'sala', cidade: s.cidade, estado: s.estado, funcao_id: null, funcao_nome: null, permissoes: null, nivel: null, })); - return [...clinicas, ...salaWs]; + // Salas das clínicas em que o usuário tem vínculo (mas NÃO é dono): acesso OPERACIONAL — + // ver a agenda da sala e bloquear horário para manutenção, sem locação/financeiro. + const { rows: salasOp } = await pool.query(` + SELECT DISTINCT s.id, s.nome, s.cidade, s.estado + FROM salas s + JOIN vinculos v ON v.clinica_id = s.clinica_id AND v.usuario_id = $1 + WHERE s.clinica_id IS NOT NULL AND s.owner_usuario_id <> $1 + ORDER BY s.nome`, [userId]); + const salaOpWs = salasOp.map(s => ({ + id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'funcionario', tipo: 'sala', + cidade: s.cidade, estado: s.estado, papel_sala: 'operador', + funcao_id: null, funcao_nome: null, permissoes: null, nivel: null, + })); + return [...clinicas, ...salaWs, ...salaOpWs]; } // Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido). @@ -5315,6 +5328,9 @@ async function runMigrations() { `CREATE INDEX IF NOT EXISTS idx_sala_reservas_clinica ON sala_reservas (clinica_id, inicio)`, // Fatia 1b: reserva confirmada vira recebível de locação no financeiro `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, + // Bloqueio de sala p/ manutenção: reserva tipo='manutencao' (valor 0, nasce confirmada, + // sem cobrança) que o pessoal da unidade pode criar para tirar a sala da agenda. + `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS tipo TEXT DEFAULT 'locacao'`, // Ativação de plugin POR USUÁRIO (add-on por conta) — independe de clínica. // É a fonte da verdade do que cada pessoa habilitou (ex.: locacao-salas). `CREATE TABLE IF NOT EXISTS usuario_plugins ( @@ -6137,8 +6153,9 @@ app.get('/api/salas/:id/reservas', authGuard, async (req, res) => { if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' }); const isOwner = sr[0].owner_usuario_id === req.authUser.userId; const { rows } = await pool.query( - `SELECT id, inicio::text, fim::text, modelo, status, valor, - CASE WHEN $2 OR solicitante_id = $3 THEN solicitante_nome ELSE NULL END AS solicitante_nome + `SELECT id, inicio::text, fim::text, modelo, status, valor, COALESCE(tipo, 'locacao') AS tipo, + CASE WHEN $2 OR solicitante_id = $3 THEN solicitante_nome ELSE NULL END AS solicitante_nome, + CASE WHEN tipo = 'manutencao' THEN observacoes ELSE NULL END AS motivo FROM sala_reservas WHERE sala_id = $1 AND status = ANY($4) AND fim > NOW() - INTERVAL '1 day' ORDER BY inicio LIMIT 300`, [req.params.id, isOwner, req.authUser.userId, RESERVA_ATIVAS]); @@ -6146,6 +6163,48 @@ app.get('/api/salas/:id/reservas', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Bloqueio de sala para MANUTENÇÃO: tira a sala da agenda num intervalo, sem cobrança. +// Quem pode: o dono da sala OU qualquer membro com vínculo na clínica dona da sala +// (ex.: recepção). Nasce 'confirmada' (não precisa aprovação) e não gera financeiro. +app.post('/api/salas/:id/manutencao', authGuard, async (req, res) => { + try { + const { inicio, fim, motivo } = req.body || {}; + if (!inicio || !fim) return res.status(400).json({ error: 'Início e fim são obrigatórios.' }); + if (new Date(fim) <= new Date(inicio)) return res.status(400).json({ error: 'Fim deve ser depois do início.' }); + const uid = req.authUser.userId; + const { rows: sr } = await pool.query('SELECT id, nome, owner_usuario_id, clinica_id FROM salas WHERE id = $1', [req.params.id]); + if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' }); + const sala = sr[0]; + const isOwner = sala.owner_usuario_id === uid; + let isDaUnidade = false; + if (!isOwner && sala.clinica_id) { + const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [uid, sala.clinica_id]); + isDaUnidade = v.length > 0; + } + if (!isOwner && !isDaUnidade) return res.status(403).json({ error: 'Sem acesso a esta sala.' }); + const nome = await actorNome(uid); + const id = `res_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await withTransaction(async (client) => { + const { rows: conf } = await client.query( + `SELECT id, inicio::text, fim::text FROM sala_reservas + WHERE sala_id = $1 AND status = ANY($4) AND inicio < $3 AND fim > $2 LIMIT 1 FOR UPDATE`, + [req.params.id, inicio, fim, RESERVA_ATIVAS]); + if (conf.length) throw { code: 'CONF_SALA', conflito: conf[0] }; + await client.query( + `INSERT INTO sala_reservas (id, sala_id, solicitante_id, solicitante_nome, inicio, fim, modelo, valor, status, observacoes, clinica_id, tipo) + VALUES ($1,$2,$3,$4,$5,$6,'hora',0,'confirmada',$7,$8,'manutencao')`, + [id, req.params.id, uid, nome, inicio, fim, motivo || 'Manutenção', sala.clinica_id || null]); + await registrarAudit(client, { clinicaId: sala.clinica_id || null, entidade: 'sala_reserva', entidadeId: id, acao: 'manutencao', actorId: uid, actorNome: nome, detalhes: { salaId: req.params.id, inicio, fim, motivo: motivo || null } }); + }); + if (!isOwner) await notificarUsuario(sala.owner_usuario_id, 'Sala bloqueada p/ manutenção', + `${nome} bloqueou a sala "${sala.nome}" para manutenção (${new Date(inicio).toLocaleString('pt-BR')}).`); + res.json({ success: true, id, status: 'confirmada', tipo: 'manutencao' }); + } catch (err) { + if (err.code === 'CONF_SALA') return res.status(409).json({ error: 'CONFLITO: já existe reserva/bloqueio neste horário.', conflito: err.conflito }); + res.status(500).json({ error: err.message }); + } +}); + // Reservar sala — pessoal OU em nome de uma unidade (clinicaId + profissional // designado opcional). Anti-conflito sobre QUEM OCUPA a sala (profissional // designado ou o próprio solicitante): (1) sala ocupada, (2) reserva ativa do diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 9215837..5a55d40 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1011,6 +1011,23 @@ export const HybridBackend = { return await res.json().catch(() => ({ feitas: [], recebidas: [] })); }, + // Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade. + getSalaReservas: async (salaId: string): Promise => { + const res = await apiFetch(`${API_URL}/salas/${salaId}/reservas`); + return await res.json().catch(() => []); + }, + + // Bloqueio de sala para manutenção (sem cobrança; tira a sala da agenda no intervalo). + criarManutencaoSala: async (salaId: string, body: { inicio: string; fim: string; motivo?: string }) => { + const res = await apiFetch(`${API_URL}/salas/${salaId}/manutencao`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body)}); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || 'Falha ao bloquear a sala.'); + return data; + }, + saveFinanceiro: async (item: Partial) => { const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; const data = enforceUppercase(item); diff --git a/frontend/views/SalaHomeView.tsx b/frontend/views/SalaHomeView.tsx index 907414c..197ad8a 100644 --- a/frontend/views/SalaHomeView.tsx +++ b/frontend/views/SalaHomeView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays } from 'lucide-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'; @@ -14,7 +14,7 @@ const H_INI = 7, H_FIM = 22, ROW_H = 40; // 07h–22h, 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[] }> = ({ reservas }) => { +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'); @@ -26,12 +26,17 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => { 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 ( -
+

{ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}

-

{r.solicitante_nome || r.clinica_nome || 'Reserva'}

+

{legenda}

); }); @@ -41,6 +46,11 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {

Agenda da sala

+ {onBloquear && ( + + )} {label} @@ -82,6 +92,63 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
Confirmada Pendente + Manutenção +
+
+ ); +}; + +// 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 }> = ({ 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 ( +
+
+
+

Bloquear p/ manutenção

+ +
+
+
+ + 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" /> +
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+
+ + 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" /> +
+ +
); @@ -92,9 +159,12 @@ 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([]); 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]); @@ -105,17 +175,25 @@ export const SalaHomeView: React.FC = () => { const carregar = useCallback(async () => { setLoading(true); try { - const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]); - setLancamentos(Array.isArray(fin) ? fin : []); - setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] }); + 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]); + }, [inicio, fim, isOperador]); useEffect(() => { carregarRel(); }, [carregarRel]); const recebido = lancamentos.filter(l => l.status === 'Pago').reduce((s, l) => s + Number(l.valor), 0); @@ -124,6 +202,13 @@ export const SalaHomeView: React.FC = () => { 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 { @@ -160,15 +245,16 @@ export const SalaHomeView: React.FC = () => { {loading ?
: ( <> -
- - +
+ {!isOperador && } + {!isOperador && }
- + setBloqueioOpen(true)} /> + {!isOperador && (<>

Recebíveis de locação

@@ -240,8 +326,10 @@ export const SalaHomeView: React.FC = () => {
)}
+ )} )} + {bloqueioOpen && setBloqueioOpen(false)} onConfirm={bloquearManutencao} />}
); };