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:
+7
-5
@@ -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);
|
||||
|
||||
@@ -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. Já 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user