feat(salas): contrato de locação — modelo padrão + aceite na reserva

Backend:
- Nova coluna salas.contrato (TEXT, migration ADD COLUMN IF NOT EXISTS).
- INSERT/UPDATE de sala aceitam `contrato`; marketplace SELECT devolve s.contrato
  (minhas salas já via s.*).

Frontend (SalasPlugin):
- CONTRATO_MODELO: modelo padrão cuja cláusula 2 deixa TODOS os procedimentos sob
  inteira responsabilidade do locatário; o dono edita por sala.
- SalaForm ganha textarea "Contrato de locação" pré-preenchida com o modelo +
  botão "Restaurar modelo".
- Passo Pagamento: botão "Ler contrato de locação" abre modal com o texto
  (contrato da sala, ou o modelo) + checkbox de aceite. "Solicitar Reserva" só
  habilita após aceitar; o aceite é registrado nas observações da reserva.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-07 15:10:40 +02:00
parent d6aa1cdeac
commit 343ca560dc
2 changed files with 79 additions and 8 deletions
+7 -5
View File
@@ -6700,6 +6700,7 @@ async function runMigrations() {
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS diaria_consulta BOOLEAN DEFAULT false`,
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS semanal_consulta BOOLEAN DEFAULT false`,
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS mensal_consulta BOOLEAN DEFAULT false`,
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS contrato TEXT`,
`UPDATE salas s SET area = CASE u.role WHEN 'biomedico' THEN 'biomedicina' WHEN 'protetico' THEN 'protese' ELSE 'odontologia' END
FROM usuarios u WHERE u.id = s.owner_usuario_id AND s.area IS NULL`,
`UPDATE salas SET area = 'odontologia' WHERE area IS NULL`,
@@ -7602,14 +7603,15 @@ app.post('/api/salas', authGuard, async (req, res) => {
await pool.query(
`INSERT INTO salas (id, owner_usuario_id, clinica_id, nome, tipo, descricao, equipamentos, endereco, bairro, cidade, estado, cep,
valor_hora, valor_turno, valor_diaria, valor_semanal, valor_mensal, disponivel, area,
hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`,
hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta, contrato)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)`,
[id, req.authUser.userId, b.clinicaId || null, b.nome, SALA_TIPOS.includes(b.tipo) ? b.tipo : 'independente',
b.descricao || null, b.equipamentos || null, b.endereco || null,
b.bairro?.toUpperCase() || null, String(b.cidade).toUpperCase(), String(b.estado).toUpperCase(), b.cep || null,
b.valorHora ?? null, b.valorTurno ?? null, b.valorDiaria ?? null, b.valorSemanal ?? null, b.valorMensal ?? null,
b.disponivel !== false, area,
b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true]);
b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true,
b.contrato || null]);
await setGeoByCep('salas', id, b.cep);
await registrarAudit(pool, { clinicaId: b.clinicaId || null, entidade: 'sala', entidadeId: id, acao: 'criou', actorId: req.authUser.userId, actorNome: await actorNome(req.authUser.userId), detalhes: { nome: b.nome } });
res.json({ success: true, id });
@@ -7626,7 +7628,7 @@ app.put('/api/salas/:id', authGuard, async (req, res) => {
endereco: b.endereco, bairro: b.bairro?.toUpperCase(), cidade: b.cidade ? String(b.cidade).toUpperCase() : undefined,
estado: b.estado ? String(b.estado).toUpperCase() : undefined, cep: b.cep,
valor_hora: b.valorHora, valor_turno: b.valorTurno, valor_diaria: b.valorDiaria, valor_semanal: b.valorSemanal, valor_mensal: b.valorMensal,
area: b.area, disponivel: b.disponivel,
area: b.area, disponivel: b.disponivel, contrato: b.contrato,
hora_consulta: b.horaConsulta, turno_consulta: b.turnoConsulta, diaria_consulta: b.diariaConsulta, semanal_consulta: b.semanalConsulta, mensal_consulta: b.mensalConsulta };
const keys = Object.keys(COLS).filter(k => COLS[k] !== undefined);
if (!keys.length) return res.json({ success: true });
@@ -7683,7 +7685,7 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
const { rows } = await pool.query(
`SELECT s.id, s.nome, s.tipo, s.descricao, s.equipamentos, s.endereco, s.bairro, s.cidade, s.estado, s.cep, s.area,
s.valor_hora, s.valor_turno, s.valor_diaria, s.valor_semanal, s.valor_mensal,
s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta,
s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta, s.contrato,
s.owner_usuario_id, u.nome AS owner_nome, c.nome_fantasia AS clinica_nome, ${distSelect}
FROM salas s LEFT JOIN usuarios u ON u.id = s.owner_usuario_id LEFT JOIN clinicas c ON c.id = s.clinica_id
${where} ${order} LIMIT 200`, params);
+72 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
DoorOpen, MapPin, Search, RefreshCw, Plus, Pencil, Trash2, X, Save,
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp, ChevronLeft, ChevronRight
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp, ChevronLeft, ChevronRight, FileText
} from 'lucide-react';
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.tsx';
@@ -41,6 +41,7 @@ interface Sala {
valor_diaria: string | null;
valor_semanal: string | null;
valor_mensal: string | null;
contrato?: string | null;
hora_consulta?: boolean;
turno_consulta?: boolean;
diaria_consulta?: boolean;
@@ -161,6 +162,29 @@ const UNIDADE: Record<string, { sing: string; plur: string; max: number }> = {
mensal: { sing: 'mês', plur: 'meses', max: 12 },
};
// Modelo padrão de contrato de locação — o dono pode editar por sala; se em branco, vale este.
const CONTRATO_MODELO = `CONTRATO DE LOCAÇÃO DE ESPAÇO / SALA
1. OBJETO
A presente locação tem por objeto a cessão temporária do espaço/sala identificado nesta plataforma, para uso profissional durante o período reservado.
2. RESPONSABILIDADE
Todos os procedimentos, atendimentos e serviços realizados no espaço durante o período locado são de INTEIRA E EXCLUSIVA RESPONSABILIDADE DO LOCATÁRIO (quem aluga), abrangendo as esferas civil, criminal, ética, sanitária e trabalhista, perante pacientes, terceiros e órgãos de classe. O LOCADOR (dono da sala) não responde por qualquer ato praticado pelo locatário.
3. USO E CONSERVAÇÃO
O locatário compromete-se a utilizar o espaço e os equipamentos com zelo, devolvendo-os nas mesmas condições em que os recebeu. Eventuais danos serão integralmente ressarcidos pelo locatário.
4. REGULARIDADE PROFISSIONAL
O locatário declara possuir registro profissional ativo e todas as licenças e autorizações exigidas para o exercício de sua atividade, isentando o locador de qualquer responsabilidade a esse respeito.
5. PAGAMENTO
O valor e a forma de pagamento são os informados no pedido de reserva e combinados diretamente entre as partes.
6. CANCELAMENTO
Cancelamentos devem ser comunicados com antecedência razoável, conforme acordado entre as partes.
Ao aceitar, o LOCATÁRIO declara ter lido e concordado integralmente com os termos acima.`;
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400';
const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
// Estilo padrão de filtros — fonte única em components/filterStyles.
@@ -184,6 +208,7 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved:
diariaConsulta: sala?.diaria_consulta === true, semanalConsulta: sala?.semanal_consulta === true,
mensalConsulta: sala?.mensal_consulta === true,
disponivel: sala?.disponivel !== false,
contrato: sala?.contrato ?? CONTRATO_MODELO,
});
const set = (k: string, v: any) => setF(p => ({ ...p, [k]: v }));
@@ -250,6 +275,14 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved:
<label className={labelCls}>Equipamentos</label>
<input className={inputCls} value={f.equipamentos} onChange={e => set('equipamentos', e.target.value)} placeholder="CADEIRA, FOTOPOLIMERIZADOR, RX..." />
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5"><FileText size={12} /> Contrato de locação</label>
<button type="button" onClick={() => set('contrato', CONTRATO_MODELO)} className="text-[9px] font-black uppercase text-teal-600 hover:text-teal-700">Restaurar modelo</button>
</div>
<textarea className={`${inputCls} font-medium normal-case`} rows={6} value={f.contrato} onChange={e => set('contrato', e.target.value)} placeholder="Termos do contrato que o locatário deverá aceitar ao reservar..." />
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">Exibido ao locatário no passo de pagamento; ele precisa aceitar para reservar. preenchemos um modelo edite como quiser.</p>
</div>
<div className="md:col-span-2">
<label className={labelCls}>Endereço</label>
<input className={inputCls} value={f.endereco} onChange={e => set('endereco', e.target.value)} placeholder="RUA / NÚMERO / COMPLEMENTO" />
@@ -352,6 +385,9 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
// ── Wizard (4 passos, formato slide) ──
const [step, setStep] = useState(0);
const [formaPagamento, setFormaPagamento] = useState('pix');
const [showContrato, setShowContrato] = useState(false);
const [contratoAceito, setContratoAceito] = useState(false);
const contratoTexto = (sala.contrato && sala.contrato.trim()) || CONTRATO_MODELO;
const STEPS = ['Dados', 'Calendário', 'Resumo', 'Pagamento'];
const FORMAS: Record<string, string> = { pix: 'PIX', dinheiro: 'Dinheiro', transferencia: 'Transferência', cartao: 'Cartão' };
const modeloLabel = MODELOS.find(m => m.id === f.modelo)?.label || '';
@@ -399,9 +435,10 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
? diasMultiSorted.map(d => computeRange(d, 'diaria', hora, 1, turno))
: (f.inicio && f.fim ? [{ ini: new Date(f.inicio), fim: new Date(f.fim) }] : []);
if (!ranges.length) { toast.error(f.modelo === 'diaria' ? 'SELECIONE AO MENOS UM DIA.' : 'INFORME INÍCIO E FIM.'); return; }
if (!contratoAceito) { toast.error('É PRECISO ACEITAR O CONTRATO DE LOCAÇÃO.'); return; }
setSaving(true);
try {
const obs = [f.observacoes, `Forma de pagamento: ${FORMAS[formaPagamento] || formaPagamento}`].filter(Boolean).join(' · ') || null;
const obs = [f.observacoes, `Forma de pagamento: ${FORMAS[formaPagamento] || formaPagamento}`, 'Contrato de locação: aceito pelo locatário'].filter(Boolean).join(' · ') || null;
const base = {
modelo: f.modelo, observacoes: obs,
clinicaId: comoUnidade && ws?.id ? ws.id : null,
@@ -694,6 +731,21 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
))}
</div>
</div>
<div>
<label className={labelCls}>Contrato de locação</label>
<button type="button" onClick={() => setShowContrato(true)}
className="w-full flex items-center justify-between rounded-xl border p-3.5 transition-colors hover:bg-gray-50"
style={{ borderColor: contratoAceito ? '#16a34a55' : `${COLOR}44`, backgroundColor: contratoAceito ? '#16a34a0d' : `${COLOR}0a` }}>
<span className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase"><FileText size={15} style={{ color: contratoAceito ? '#16a34a' : COLOR }} /> Ler contrato de locação</span>
<span className="text-[10px] font-black uppercase flex items-center gap-1" style={{ color: contratoAceito ? '#16a34a' : COLOR }}>
{contratoAceito ? <><CheckCircle2 size={13} /> Aceito</> : 'Abrir'}
</span>
</button>
<label className="flex items-start gap-2 mt-2 px-1 cursor-pointer">
<input type="checkbox" checked={contratoAceito} onChange={e => setContratoAceito(e.target.checked)} className="mt-0.5 w-4 h-4 accent-teal-600 shrink-0" />
<span className="text-[11px] font-bold text-gray-600 leading-snug">Li e concordo com o contrato de locação os procedimentos realizados são de minha inteira responsabilidade.</span>
</label>
</div>
<div className="rounded-xl bg-gray-50 border border-gray-100 p-3.5 text-[11px] text-gray-500 leading-relaxed">
<span className="font-black text-gray-600 uppercase">Como funciona: </span>ao solicitar, o locador recebe seu pedido e confirma. O pagamento é combinado diretamente com ele após a confirmação sua preferência ({FORMAS[formaPagamento]}) vai junto no pedido.
</div>
@@ -713,13 +765,30 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
Próximo <ChevronRight size={14} />
</button>
) : (
<button onClick={handleReserve} disabled={saving || !temSelecao}
<button onClick={handleReserve} disabled={saving || !temSelecao || !contratoAceito}
className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-opacity hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} Solicitar Reserva
</button>
)}
</div>
</div>
{/* Modal: contrato de locação */}
{showContrato && (
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowContrato(false)}>
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
<h4 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><FileText size={16} style={{ color: COLOR }} /> Contrato de locação</h4>
<button onClick={() => setShowContrato(false)} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><X size={16} /></button>
</div>
<div className="p-5 overflow-y-auto text-[11px] text-gray-600 leading-relaxed whitespace-pre-wrap font-medium">{contratoTexto}</div>
<div className="p-4 border-t border-gray-100 shrink-0 flex gap-2">
<button onClick={() => setShowContrato(false)} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50">Fechar</button>
<button onClick={() => { setContratoAceito(true); setShowContrato(false); }} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}><CheckCircle2 size={14} /> Li e concordo</button>
</div>
</div>
</div>
)}
</div>
);
};