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>
This commit is contained in:
+62
-3
@@ -518,7 +518,20 @@ async function listarWorkspaces(userId, userRole) {
|
|||||||
id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'donosala', tipo: 'sala',
|
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,
|
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).
|
// 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)`,
|
`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
|
// Fatia 1b: reserva confirmada vira recebível de locação no financeiro
|
||||||
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS financeiro_id TEXT`,
|
`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.
|
// 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).
|
// É a fonte da verdade do que cada pessoa habilitou (ex.: locacao-salas).
|
||||||
`CREATE TABLE IF NOT EXISTS usuario_plugins (
|
`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.' });
|
if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' });
|
||||||
const isOwner = sr[0].owner_usuario_id === req.authUser.userId;
|
const isOwner = sr[0].owner_usuario_id === req.authUser.userId;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, inicio::text, fim::text, modelo, status, valor,
|
`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 $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'
|
FROM sala_reservas WHERE sala_id = $1 AND status = ANY($4) AND fim > NOW() - INTERVAL '1 day'
|
||||||
ORDER BY inicio LIMIT 300`,
|
ORDER BY inicio LIMIT 300`,
|
||||||
[req.params.id, isOwner, req.authUser.userId, RESERVA_ATIVAS]);
|
[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 }); }
|
} 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
|
// Reservar sala — pessoal OU em nome de uma unidade (clinicaId + profissional
|
||||||
// designado opcional). Anti-conflito sobre QUEM OCUPA a sala (profissional
|
// designado opcional). Anti-conflito sobre QUEM OCUPA a sala (profissional
|
||||||
// designado ou o próprio solicitante): (1) sala ocupada, (2) reserva ativa do
|
// designado ou o próprio solicitante): (1) sala ocupada, (2) reserva ativa do
|
||||||
|
|||||||
@@ -1011,6 +1011,23 @@ export const HybridBackend = {
|
|||||||
return await res.json().catch(() => ({ feitas: [], recebidas: [] }));
|
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<any[]> => {
|
||||||
|
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<FinanceiroItem>) => {
|
saveFinanceiro: async (item: Partial<FinanceiroItem>) => {
|
||||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||||
const data = enforceUppercase(item);
|
const data = enforceUppercase(item);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
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 { HybridBackend } from '../services/backend.ts';
|
||||||
import { useToast } from '../contexts/ToastContext.tsx';
|
import { useToast } from '../contexts/ToastContext.tsx';
|
||||||
import { useConfirm } from '../contexts/ConfirmContext.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 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 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 [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 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 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;
|
if (!sameDay(ini, dia)) return null;
|
||||||
const top = Math.max(0, (ini.getHours() + ini.getMinutes() / 60 - H_INI) * ROW_H);
|
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 height = Math.max(16, ((fim.getTime() - ini.getTime()) / 3600000) * ROW_H);
|
||||||
|
const manut = r.tipo === 'manutencao';
|
||||||
const conf = r.status === 'confirmada';
|
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 (
|
return (
|
||||||
<div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${conf ? 'bg-teal-100 border-teal-300 text-teal-800' : 'bg-amber-100 border-amber-300 text-amber-800'}`}
|
<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={`${r.solicitante_nome || r.clinica_nome || ''} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}–${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}>
|
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-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">{r.solicitante_nome || r.clinica_nome || 'Reserva'}</p>
|
<p className="text-[9px] font-bold leading-tight truncate uppercase">{legenda}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -41,6 +46,11 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
|
|||||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between gap-3">
|
<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>
|
<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">
|
<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>
|
<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(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(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>
|
||||||
@@ -82,6 +92,63 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
|
|||||||
<div className="px-6 py-2 flex gap-4 text-[10px] font-bold text-gray-400 uppercase border-t border-gray-50">
|
<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-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-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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -92,9 +159,12 @@ export const SalaHomeView: React.FC = () => {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const ws: any = HybridBackend.getActiveWorkspace();
|
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 [lancamentos, setLancamentos] = useState<any[]>([]);
|
||||||
const [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] });
|
const [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [bloqueioOpen, setBloqueioOpen] = useState(false);
|
||||||
// Relatório por sala (período)
|
// Relatório por sala (período)
|
||||||
const hoje = new Date();
|
const hoje = new Date();
|
||||||
const [inicio, setInicio] = useState(new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().split('T')[0]);
|
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 () => {
|
const carregar = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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()]);
|
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
|
||||||
setLancamentos(Array.isArray(fin) ? fin : []);
|
setLancamentos(Array.isArray(fin) ? fin : []);
|
||||||
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
|
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
|
||||||
|
}
|
||||||
} catch { /* silent */ } finally { setLoading(false); }
|
} catch { /* silent */ } finally { setLoading(false); }
|
||||||
}, []);
|
}, [isOperador, ws?.id]);
|
||||||
useEffect(() => { carregar(); }, [carregar]);
|
useEffect(() => { carregar(); }, [carregar]);
|
||||||
|
|
||||||
const carregarRel = useCallback(() => {
|
const carregarRel = useCallback(() => {
|
||||||
|
if (isOperador) { setRel(null); return; }
|
||||||
setRelLoading(true);
|
setRelLoading(true);
|
||||||
HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false));
|
HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false));
|
||||||
}, [inicio, fim]);
|
}, [inicio, fim, isOperador]);
|
||||||
useEffect(() => { carregarRel(); }, [carregarRel]);
|
useEffect(() => { carregarRel(); }, [carregarRel]);
|
||||||
|
|
||||||
const recebido = lancamentos.filter(l => l.status === 'Pago').reduce((s, l) => s + Number(l.valor), 0);
|
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 pendentes = reservasDaSala.filter(r => r.status === 'pendente').length;
|
||||||
const confirmadas = reservasDaSala.filter(r => r.status === 'confirmada').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) => {
|
const darBaixa = async (l: any) => {
|
||||||
if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return;
|
if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return;
|
||||||
try {
|
try {
|
||||||
@@ -160,15 +245,16 @@ export const SalaHomeView: React.FC = () => {
|
|||||||
|
|
||||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></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 lg:grid-cols-4 gap-4">
|
<div className={`grid grid-cols-2 ${isOperador ? 'lg:grid-cols-2' : 'lg:grid-cols-4'} gap-4`}>
|
||||||
<KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />
|
{!isOperador && <KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />}
|
||||||
<KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />
|
{!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 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" />
|
<KPI titulo="Reservas confirmadas" valor={String(confirmadas)} cor="text-teal-600" Icon={CalendarClock} sub="Nesta sala" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AgendaSala reservas={reservasDaSala} />
|
<AgendaSala reservas={reservasDaSala} onBloquear={() => setBloqueioOpen(true)} />
|
||||||
|
|
||||||
|
{!isOperador && (<>
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
<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">
|
<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>
|
<h3 className="text-sm font-black text-gray-800 uppercase">Recebíveis de locação</h3>
|
||||||
@@ -240,8 +326,10 @@ export const SalaHomeView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{bloqueioOpen && <BloqueioModal onClose={() => setBloqueioOpen(false)} onConfirm={bloquearManutencao} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user