3e64514b08
Financeiro V1 (Épicos 1–4): - FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado" - prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional) - normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI) - baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão - relatório financeiro (período, paciente, profissional, forma, convênio, glosa) - orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato Financeiro V2 (Comissão/Repasse): - regras de % (padrão/procedimento/override por profissional) + custo de laboratório - base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos) Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS; sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala) Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
788 lines
44 KiB
TypeScript
788 lines
44 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
DoorOpen, MapPin, Search, RefreshCw, Plus, Pencil, Trash2, X, Save,
|
|
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send
|
|
} from 'lucide-react';
|
|
import { PageHeader } from '../../components/PageHeader.tsx';
|
|
import { useToast } from '../../contexts/ToastContext.tsx';
|
|
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
|
|
import { HybridBackend } from '../../services/backend.ts';
|
|
import { buscarCep } from '../../services/viacep.ts';
|
|
|
|
const API_URL = (window as any).__API_URL__ || '';
|
|
const UF_LIST = ['', 'AC', 'AL', 'AM', 'AP', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MG', 'MS', 'MT', 'PA', 'PB', 'PE', 'PI', 'PR', 'RJ', 'RN', 'RO', 'RR', 'RS', 'SC', 'SE', 'SP', 'TO'];
|
|
|
|
const COLOR = '#0d9488'; // teal — identidade do plugin
|
|
|
|
const apiFetch = (path: string, opts: RequestInit = {}) =>
|
|
fetch(`${API_URL}${path}`, {
|
|
...opts,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`,
|
|
...(opts.headers || {}),
|
|
},
|
|
});
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
interface Sala {
|
|
id: string;
|
|
nome: string;
|
|
tipo: 'clinica' | 'consultorio' | 'independente';
|
|
descricao: string | null;
|
|
equipamentos: string | null;
|
|
endereco: string | null;
|
|
bairro: string | null;
|
|
cidade: string;
|
|
estado: string;
|
|
cep: string | null;
|
|
valor_hora: string | null;
|
|
valor_turno: string | null;
|
|
valor_diaria: string | null;
|
|
valor_semanal: string | null;
|
|
valor_mensal: string | null;
|
|
hora_consulta?: boolean;
|
|
turno_consulta?: boolean;
|
|
diaria_consulta?: boolean;
|
|
semanal_consulta?: boolean;
|
|
mensal_consulta?: boolean;
|
|
area?: string;
|
|
disponivel?: boolean;
|
|
owner_nome?: string;
|
|
clinica_id?: string | null;
|
|
clinica_nome?: string | null;
|
|
reservas_pendentes?: number;
|
|
dist_km?: number | string | null;
|
|
}
|
|
|
|
interface Reserva {
|
|
id: string;
|
|
sala_id: string;
|
|
sala_nome?: string;
|
|
solicitante_nome?: string;
|
|
owner_nome?: string;
|
|
cidade?: string;
|
|
estado?: string;
|
|
inicio: string;
|
|
fim: string;
|
|
modelo: string;
|
|
valor?: number | string | null;
|
|
status: 'pendente' | 'confirmada' | 'recusada' | 'cancelada';
|
|
observacoes?: string | null;
|
|
clinica_nome?: string | null;
|
|
profissional_nome?: string | null;
|
|
}
|
|
|
|
const TIPO_META: Record<string, { label: string; icon: any }> = {
|
|
clinica: { label: 'Sala Clínica', icon: Building2 },
|
|
consultorio: { label: 'Sala Consultório', icon: Briefcase },
|
|
independente: { label: 'Sala Independente', icon: Home },
|
|
};
|
|
|
|
const AREA_META: Record<string, { nome: string; cor: string }> = {
|
|
odontologia: { nome: 'Odontologia', cor: '#2563eb' },
|
|
biomedicina: { nome: 'Biomedicina', cor: '#db2777' },
|
|
protese: { nome: 'Prótese', cor: '#7c3aed' },
|
|
};
|
|
|
|
const STATUS_STYLE: Record<string, string> = {
|
|
pendente: 'bg-amber-100 text-amber-700',
|
|
confirmada: 'bg-emerald-100 text-emerald-700',
|
|
recusada: 'bg-red-100 text-red-600',
|
|
cancelada: 'bg-gray-100 text-gray-500',
|
|
};
|
|
|
|
const fmtValor = (v: string | null) => (v == null || v === '' ? null : Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }));
|
|
const fmtData = (iso: string) => new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
|
|
const precos = (s: Sala): Array<[string, string]> => {
|
|
const mods: Array<[string, string | null, boolean | undefined]> = [
|
|
['HORA', s.valor_hora, s.hora_consulta], ['TURNO', s.valor_turno, s.turno_consulta],
|
|
['DIÁRIA', s.valor_diaria, s.diaria_consulta], ['SEMANA', s.valor_semanal, s.semanal_consulta],
|
|
['MÊS', s.valor_mensal, s.mensal_consulta],
|
|
];
|
|
return mods
|
|
.filter(([, v, c]) => fmtValor(v as string | null) || c)
|
|
.map(([l, v]) => [l, fmtValor(v as string | null) || 'Sob consulta'] as [string, string]);
|
|
};
|
|
|
|
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';
|
|
|
|
// ─── Modal: cadastrar / editar sala ──────────────────────────────────────────
|
|
const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved: () => void }> = ({ sala, onClose, onSaved }) => {
|
|
const toast = useToast();
|
|
const ws = HybridBackend.getActiveWorkspace();
|
|
const [saving, setSaving] = useState(false);
|
|
const [vincularUnidade, setVincularUnidade] = useState(sala ? !!sala.clinica_id : !!ws?.id);
|
|
const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]);
|
|
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
|
|
const [f, setF] = useState({
|
|
nome: sala?.nome || '', tipo: sala?.tipo || 'independente', descricao: sala?.descricao || '',
|
|
equipamentos: sala?.equipamentos || '', endereco: sala?.endereco || '', bairro: sala?.bairro || '',
|
|
cidade: sala?.cidade || '', estado: sala?.estado || '', cep: sala?.cep || '', area: sala?.area || '',
|
|
valorHora: sala?.valor_hora || '', valorTurno: sala?.valor_turno || '', valorDiaria: sala?.valor_diaria || '',
|
|
valorSemanal: sala?.valor_semanal || '', valorMensal: sala?.valor_mensal || '',
|
|
horaConsulta: sala?.hora_consulta === true, turnoConsulta: sala?.turno_consulta === true,
|
|
diariaConsulta: sala?.diaria_consulta === true, semanalConsulta: sala?.semanal_consulta === true,
|
|
mensalConsulta: sala?.mensal_consulta === true,
|
|
disponivel: sala?.disponivel !== false,
|
|
});
|
|
const set = (k: string, v: any) => setF(p => ({ ...p, [k]: v }));
|
|
|
|
const handleSave = async () => {
|
|
if (!f.nome || !f.cidade || !f.estado) { toast.error('NOME, CIDADE E ESTADO SÃO OBRIGATÓRIOS.'); return; }
|
|
setSaving(true);
|
|
try {
|
|
const body = JSON.stringify({
|
|
...f,
|
|
clinicaId: vincularUnidade && ws?.id ? ws.id : null,
|
|
valorHora: f.valorHora === '' ? null : +f.valorHora, valorTurno: f.valorTurno === '' ? null : +f.valorTurno,
|
|
valorDiaria: f.valorDiaria === '' ? null : +f.valorDiaria, valorSemanal: f.valorSemanal === '' ? null : +f.valorSemanal,
|
|
valorMensal: f.valorMensal === '' ? null : +f.valorMensal,
|
|
});
|
|
const res = sala
|
|
? await apiFetch(`/api/salas/${sala.id}`, { method: 'PUT', body })
|
|
: await apiFetch('/api/salas', { method: 'POST', body });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao salvar.');
|
|
toast.success(sala ? 'SALA ATUALIZADA!' : 'SALA CADASTRADA!');
|
|
// Sala é workspace próprio: atualiza o seletor. Se for a 1ª (sem workspace ativo), recarrega p/ assumir contexto.
|
|
const semWs = !HybridBackend.getActiveWorkspace();
|
|
await HybridBackend.refreshWorkspaces();
|
|
if (!sala && semWs) { window.location.reload(); return; }
|
|
onSaved();
|
|
onClose();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl overflow-hidden max-h-[92vh] overflow-y-auto">
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white">
|
|
<h3 className="text-sm font-black text-gray-900 uppercase">{sala ? 'EDITAR SALA' : 'CADASTRAR SALA'}</h3>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div className="md:col-span-2">
|
|
<label className={labelCls}>Nome da sala *</label>
|
|
<input className={inputCls} value={f.nome} onChange={e => set('nome', e.target.value)} placeholder="EX: SALA 2 — EQUIPADA P/ HOF" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Tipo</label>
|
|
<select className={inputCls} value={f.tipo} onChange={e => set('tipo', e.target.value)}>
|
|
<option value="clinica">SALA CLÍNICA</option>
|
|
<option value="consultorio">SALA CONSULTÓRIO</option>
|
|
<option value="independente">SALA INDEPENDENTE</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Disponível p/ locação</label>
|
|
<select className={inputCls} value={f.disponivel ? '1' : '0'} onChange={e => set('disponivel', e.target.value === '1')}>
|
|
<option value="1">SIM</option>
|
|
<option value="0">NÃO</option>
|
|
</select>
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className={labelCls}>Descrição</label>
|
|
<textarea className={inputCls} rows={2} value={f.descricao} onChange={e => set('descricao', e.target.value)} placeholder="Detalhes da sala, regras de uso..." />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<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">
|
|
<label className={labelCls}>Endereço</label>
|
|
<input className={inputCls} value={f.endereco} onChange={e => set('endereco', e.target.value)} placeholder="RUA / NÚMERO / COMPLEMENTO" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Bairro</label>
|
|
<input className={`${inputCls} uppercase`} value={f.bairro} onChange={e => set('bairro', e.target.value.toUpperCase())} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>CEP</label>
|
|
<input className={inputCls} value={f.cep} onChange={async e => {
|
|
const v = e.target.value;
|
|
set('cep', v);
|
|
if (v.replace(/\D/g, '').length === 8) {
|
|
const r = await buscarCep(v);
|
|
if (r) { setF(p => ({ ...p, endereco: r.logradouro, bairro: r.bairro, cidade: r.cidade, estado: r.estado })); toast.success('ENDEREÇO PREENCHIDO PELO CEP!'); }
|
|
else toast.error('CEP NÃO ENCONTRADO.');
|
|
}
|
|
}} placeholder="79000-000" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Cidade *</label>
|
|
<input className={`${inputCls} uppercase`} value={f.cidade} onChange={e => set('cidade', e.target.value.toUpperCase())} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Estado *</label>
|
|
<select className={inputCls} value={f.estado} onChange={e => set('estado', e.target.value)}>
|
|
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'SELECIONE'}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{ws?.id && (
|
|
<label className="flex items-center gap-3 p-3 rounded-xl border border-gray-100 bg-gray-50/60 cursor-pointer">
|
|
<input type="checkbox" checked={vincularUnidade} onChange={e => setVincularUnidade(e.target.checked)} className="w-4 h-4 accent-teal-600" />
|
|
<span className="text-[11px] font-black text-gray-600 uppercase">Anunciar pela unidade: {ws.nome}</span>
|
|
</label>
|
|
)}
|
|
<div>
|
|
<label className={labelCls}>Área da sala</label>
|
|
<select className={inputCls} value={f.area} onChange={e => set('area', e.target.value)}>
|
|
<option value="">— Área (padrão pelo seu perfil) —</option>
|
|
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<p className={labelCls}>Valores de locação — fixo (R$) ou "sob consulta" por modalidade</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
{([['valorHora', 'horaConsulta', 'HORA'], ['valorTurno', 'turnoConsulta', 'TURNO'], ['valorDiaria', 'diariaConsulta', 'DIÁRIA'], ['valorSemanal', 'semanalConsulta', 'SEMANAL'], ['valorMensal', 'mensalConsulta', 'MENSAL']] as const).map(([vk, ck, l]) => (
|
|
<div key={vk} className="flex items-center gap-2">
|
|
<label className={`${labelCls} w-16 mb-0 shrink-0`}>{l}</label>
|
|
<input type="number" min="0" disabled={(f as any)[ck]} value={(f as any)[ck] ? '' : (f as any)[vk]} onChange={e => set(vk, e.target.value)}
|
|
className={`${inputCls} flex-1 ${(f as any)[ck] ? 'opacity-40' : ''}`} placeholder={(f as any)[ck] ? 'sob consulta' : '—'} />
|
|
<label className="flex items-center gap-1 text-[9px] font-bold text-gray-500 uppercase cursor-pointer whitespace-nowrap">
|
|
<input type="checkbox" checked={(f as any)[ck]} onChange={e => set(ck, e.target.checked)} className="w-3.5 h-3.5 accent-teal-600" /> sob consulta
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 pb-6 flex gap-3">
|
|
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
|
|
<button onClick={handleSave} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: COLOR }}>
|
|
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />} SALVAR
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Modal: reservar sala ────────────────────────────────────────────────────
|
|
const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () => void }> = ({ sala, onClose, onReserved }) => {
|
|
const toast = useToast();
|
|
const ws = HybridBackend.getActiveWorkspace();
|
|
const [saving, setSaving] = useState(false);
|
|
const [ocupadas, setOcupadas] = useState<Reserva[]>([]);
|
|
const [f, setF] = useState({ inicio: '', fim: '', modelo: 'hora', observacoes: '' });
|
|
// Reserva institucional: em nome da unidade ativa, com profissional designado opcional.
|
|
const [comoUnidade, setComoUnidade] = useState(false);
|
|
const [profissionais, setProfissionais] = useState<Array<{ usuario_id: string; nome: string }>>([]);
|
|
const [profissionalId, setProfissionalId] = useState('');
|
|
|
|
useEffect(() => {
|
|
apiFetch(`/api/salas/${sala.id}/reservas`)
|
|
.then(r => (r.ok ? r.json() : []))
|
|
.then(setOcupadas)
|
|
.catch(() => {});
|
|
}, [sala.id]);
|
|
|
|
useEffect(() => {
|
|
if (!comoUnidade || !ws?.id || profissionais.length) return;
|
|
apiFetch(`/api/dentistas?clinicaId=${encodeURIComponent(ws.id)}`)
|
|
.then(r => (r.ok ? r.json() : []))
|
|
.then((ds: any[]) => setProfissionais(ds.filter(d => d.usuario_id).map(d => ({ usuario_id: d.usuario_id, nome: d.nome }))))
|
|
.catch(() => {});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [comoUnidade]);
|
|
|
|
const handleReserve = async () => {
|
|
if (!f.inicio || !f.fim) { toast.error('INFORME INÍCIO E FIM.'); return; }
|
|
setSaving(true);
|
|
try {
|
|
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
inicio: new Date(f.inicio).toISOString(), fim: new Date(f.fim).toISOString(), modelo: f.modelo, observacoes: f.observacoes || null,
|
|
clinicaId: comoUnidade && ws?.id ? ws.id : null,
|
|
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao reservar.');
|
|
toast.success('RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
|
|
onReserved();
|
|
onClose();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden max-h-[92vh] overflow-y-auto">
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-black text-gray-900 uppercase">RESERVAR {sala.nome}</h3>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: COLOR }}>{[sala.cidade, sala.estado].filter(Boolean).join(' · ')}</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
{ws?.id && (
|
|
<div>
|
|
<label className={labelCls}>Reservar como</label>
|
|
<div className="flex gap-2">
|
|
{[{ v: false, l: 'PESSOAL' }, { v: true, l: ws.nome?.toUpperCase() || 'UNIDADE' }].map(o => (
|
|
<button key={String(o.v)} type="button" onClick={() => setComoUnidade(o.v)}
|
|
className={`flex-1 py-2.5 rounded-xl text-[10px] font-black uppercase border transition-all truncate px-2 ${comoUnidade === o.v ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
|
|
style={comoUnidade === o.v ? { backgroundColor: COLOR } : {}}>
|
|
{o.l}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{comoUnidade && (
|
|
<div>
|
|
<label className={labelCls}>Profissional que vai usar a sala (opcional)</label>
|
|
<select className={inputCls} value={profissionalId} onChange={e => setProfissionalId(e.target.value)}>
|
|
<option value="">EU MESMO ({HybridBackend.getCurrentUserName()?.toUpperCase() || 'SOLICITANTE'})</option>
|
|
{profissionais.map(p => <option key={p.usuario_id} value={p.usuario_id}>{p.nome?.toUpperCase()}</option>)}
|
|
</select>
|
|
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">O anti-conflito verifica a agenda do profissional selecionado</p>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className={labelCls}>Início *</label>
|
|
<input type="datetime-local" className={inputCls} value={f.inicio} onChange={e => setF(p => ({ ...p, inicio: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Fim *</label>
|
|
<input type="datetime-local" className={inputCls} value={f.fim} onChange={e => setF(p => ({ ...p, fim: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Modelo de locação</label>
|
|
<select className={inputCls} value={f.modelo} onChange={e => setF(p => ({ ...p, modelo: e.target.value }))}>
|
|
{precos(sala).length === 0 && <option value="hora">HORA</option>}
|
|
{precos(sala).map(([l]) => {
|
|
const map: Record<string, string> = { HORA: 'hora', TURNO: 'turno', 'DIÁRIA': 'diaria', SEMANA: 'semanal', 'MÊS': 'mensal' };
|
|
return <option key={l} value={map[l]}>{l}</option>;
|
|
})}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Observações</label>
|
|
<textarea className={inputCls} rows={2} value={f.observacoes} onChange={e => setF(p => ({ ...p, observacoes: e.target.value }))} />
|
|
</div>
|
|
{ocupadas.length > 0 && (
|
|
<div className="p-3 rounded-xl bg-amber-50 border border-amber-100">
|
|
<p className="text-[9px] font-black text-amber-600 uppercase tracking-widest mb-1.5 flex items-center gap-1"><Clock size={11} /> Horários já ocupados</p>
|
|
<div className="space-y-1 max-h-28 overflow-y-auto">
|
|
{ocupadas.map(o => (
|
|
<p key={o.id} className="text-[10px] font-bold text-amber-700">{fmtData(o.inicio)} → {fmtData(o.fim)} ({o.status})</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-6 pb-6 flex gap-3">
|
|
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
|
|
<button onClick={handleReserve} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: COLOR }}>
|
|
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} SOLICITAR RESERVA
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Card de sala (marketplace + minhas) ────────────────────────────────────
|
|
const SalaCard: React.FC<{ sala: Sala; mine?: boolean; onReserve?: () => void; onEdit?: () => void; onDelete?: () => void }> = ({ sala, mine, onReserve, onEdit, onDelete }) => {
|
|
const meta = TIPO_META[sala.tipo] || TIPO_META.independente;
|
|
const Icon = meta.icon;
|
|
return (
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 hover:shadow-md transition-all">
|
|
<div className="flex items-start gap-3 mb-3">
|
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: `${COLOR}15` }}>
|
|
<Icon size={18} style={{ color: COLOR }} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-black text-gray-900 text-sm uppercase truncate">{sala.nome}</span>
|
|
<span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase" style={{ backgroundColor: `${COLOR}15`, color: COLOR }}>{meta.label}</span>
|
|
{sala.area && AREA_META[sala.area] && <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase text-white" style={{ backgroundColor: AREA_META[sala.area].cor }}>{AREA_META[sala.area].nome}</span>}
|
|
{mine && sala.disponivel === false && <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-gray-100 text-gray-500">OCULTA</span>}
|
|
{mine && (sala.reservas_pendentes || 0) > 0 && <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-amber-100 text-amber-700">{sala.reservas_pendentes} PENDENTE{sala.reservas_pendentes! > 1 ? 'S' : ''}</span>}
|
|
</div>
|
|
{!mine && (sala.clinica_nome || sala.owner_nome) && <p className="text-[10px] text-gray-400 font-bold uppercase mt-0.5">POR {sala.clinica_nome || sala.owner_nome}</p>}
|
|
{mine && sala.clinica_nome && <p className="text-[10px] text-gray-400 font-bold uppercase mt-0.5">ANUNCIADA POR {sala.clinica_nome}</p>}
|
|
</div>
|
|
</div>
|
|
{sala.descricao && <p className="text-xs text-gray-500 font-medium mb-2 line-clamp-2">{sala.descricao}</p>}
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium mb-2">
|
|
<MapPin size={12} className="shrink-0 text-gray-400" />
|
|
<span>{[sala.endereco, sala.bairro, sala.cidade, sala.estado].filter(Boolean).join(' · ')}</span>
|
|
{sala.dist_km != null && <span className="ml-auto shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full" style={{ backgroundColor: `${COLOR}15`, color: COLOR }}>~{sala.dist_km} KM</span>}
|
|
</div>
|
|
{sala.equipamentos && (
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase mb-3">EQUIP.: {sala.equipamentos}</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
|
{precos(sala).map(([l, v]) => (
|
|
<span key={l} className="text-[9px] font-black px-2 py-1 rounded-lg bg-gray-50 border border-gray-100 text-gray-600 uppercase">{l}: {fmtValor(v)}</span>
|
|
))}
|
|
{precos(sala).length === 0 && <span className="text-[9px] font-bold text-gray-300 uppercase">Valores a combinar</span>}
|
|
</div>
|
|
<div className="flex gap-2 pt-2 border-t border-gray-50">
|
|
{mine ? (
|
|
<>
|
|
<button onClick={onEdit} className="flex-1 py-2.5 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors"><Pencil size={12} /> EDITAR</button>
|
|
<button onClick={onDelete} className="px-4 py-2.5 rounded-xl border border-red-100 text-red-400 hover:bg-red-50 transition-colors"><Trash2 size={14} /></button>
|
|
</>
|
|
) : (
|
|
<button onClick={onReserve} className="flex-1 py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
|
<CalendarClock size={13} /> RESERVAR
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Lista de reservas ───────────────────────────────────────────────────────
|
|
const ReservaRow: React.FC<{ r: Reserva; recebida?: boolean; onAction: (acao: 'confirmar' | 'recusar' | 'cancelar') => void }> = ({ r, recebida, onAction }) => (
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex flex-col md:flex-row md:items-center gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-black text-gray-900 text-xs uppercase">{r.sala_nome || r.sala_id}</span>
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_STYLE[r.status] || STATUS_STYLE.cancelada}`}>{r.status}</span>
|
|
<span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-gray-50 text-gray-500">{r.modelo}</span>
|
|
</div>
|
|
<p className="text-[11px] text-gray-500 font-bold mt-1">{fmtData(r.inicio)} → {fmtData(r.fim)}</p>
|
|
<p className="text-[10px] text-gray-400 font-medium uppercase mt-0.5">
|
|
{recebida
|
|
? <>SOLICITANTE: {r.clinica_nome ? `${r.clinica_nome} (${r.solicitante_nome || '—'})` : (r.solicitante_nome || '—')}</>
|
|
: <>LOCADOR: {r.owner_nome || '—'} · {[r.cidade, r.estado].filter(Boolean).join('/')}{r.clinica_nome ? <> · EM NOME DE {r.clinica_nome}</> : null}</>}
|
|
</p>
|
|
{r.profissional_nome && <p className="text-[10px] text-gray-400 font-medium uppercase mt-0.5">PROFISSIONAL: {r.profissional_nome}</p>}
|
|
{r.observacoes && <p className="text-[10px] text-gray-400 mt-0.5">OBS: {r.observacoes}</p>}
|
|
</div>
|
|
{['pendente', 'confirmada'].includes(r.status) && (
|
|
<div className="flex gap-2 shrink-0">
|
|
{recebida && r.status === 'pendente' && (
|
|
<>
|
|
<button onClick={() => onAction('confirmar')} className="px-3 py-2 rounded-xl bg-emerald-500 text-white text-[10px] font-black uppercase flex items-center gap-1 hover:bg-emerald-600 transition-colors"><CheckCircle2 size={12} /> CONFIRMAR</button>
|
|
<button onClick={() => onAction('recusar')} className="px-3 py-2 rounded-xl border border-red-200 text-red-500 text-[10px] font-black uppercase flex items-center gap-1 hover:bg-red-50 transition-colors"><XCircle size={12} /> RECUSAR</button>
|
|
</>
|
|
)}
|
|
<button onClick={() => onAction('cancelar')} className="px-3 py-2 rounded-xl border border-gray-200 text-gray-400 text-[10px] font-black uppercase hover:bg-gray-50 transition-colors">CANCELAR</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ─── Main view ───────────────────────────────────────────────────────────────
|
|
type Tab = 'marketplace' | 'minhas' | 'reservas';
|
|
|
|
// Bloqueio quando falta endereço/CEP da origem (clínica ou perfil) — a busca é por proximidade.
|
|
const GateEndereco: React.FC<{ isClinic: boolean }> = ({ isClinic }) => (
|
|
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
|
|
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center mx-auto mb-4">
|
|
<MapPin size={26} className="text-amber-500" />
|
|
</div>
|
|
<h3 className="text-base font-black uppercase tracking-tight text-gray-900">Complete seu endereço</h3>
|
|
<p className="text-sm text-gray-500 mt-2 max-w-md mx-auto leading-relaxed">
|
|
A busca de salas é por proximidade. Preencha {isClinic
|
|
? 'a localização e o CEP da sua clínica/consultório (em Configurações)'
|
|
: 'a localização e o CEP do seu perfil (em Profissionais → Meu Perfil)'} para continuar.
|
|
</p>
|
|
<button onClick={() => window.location.assign(isClinic ? '/configuracoes' : '/marketplace-profissionais')}
|
|
className="mt-5 inline-flex items-center gap-2 px-5 py-3 rounded-xl text-white text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity"
|
|
style={{ backgroundColor: COLOR }}>
|
|
{isClinic ? 'Ir para Configurações' : 'Completar meu perfil'}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view }) => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const [tab, setTab] = useState<Tab>(view === 'minhas' ? 'minhas' : 'marketplace');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// marketplace
|
|
const [salas, setSalas] = useState<Sala[]>([]);
|
|
const [fTipo, setFTipo] = useState('');
|
|
const [fEstado, setFEstado] = useState('');
|
|
const [fCidade, setFCidade] = useState('');
|
|
const [fCep, setFCep] = useState('');
|
|
const [fRaio, setFRaio] = useState('');
|
|
const [fArea, setFArea] = useState('');
|
|
const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]);
|
|
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
|
|
// minhas
|
|
const [minhas, setMinhas] = useState<Sala[]>([]);
|
|
// reservas
|
|
const [reservas, setReservas] = useState<{ feitas: Reserva[]; recebidas: Reserva[] }>({ feitas: [], recebidas: [] });
|
|
|
|
const [editingSala, setEditingSala] = useState<Sala | null | 'new'>(null);
|
|
const [reservingSala, setReservingSala] = useState<Sala | null>(null);
|
|
|
|
// Endereço da origem (clínica ou perfil) preenchido? null = checando.
|
|
const role = HybridBackend.getCurrentRole();
|
|
const ws = HybridBackend.getActiveWorkspace();
|
|
const isProfissional = ['dentista', 'biomedico', 'protetico'].includes(role);
|
|
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
|
|
|
|
const checkEndereco = useCallback(async () => {
|
|
try {
|
|
if (isProfissional) {
|
|
const res = await apiFetch('/api/profissionais/perfil');
|
|
const p = res.ok ? await res.json() : {};
|
|
setEnderecoOk(!!(p?.cep && p?.cidade && p?.estado));
|
|
} else if (ws?.id) {
|
|
const r = await HybridBackend.getClinicaProfile(ws.id);
|
|
const c: any = r?.clinica || {};
|
|
setEnderecoOk(!!(c.cep && c.cidade && c.estado));
|
|
} else {
|
|
setEnderecoOk(true);
|
|
}
|
|
} catch { setEnderecoOk(true); }
|
|
}, [isProfissional, ws?.id]);
|
|
|
|
useEffect(() => { checkEndereco(); }, [checkEndereco]);
|
|
|
|
const fetchMarketplace = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (fTipo) params.set('tipo', fTipo);
|
|
if (fEstado) params.set('estado', fEstado);
|
|
if (fCidade) params.set('cidade', fCidade);
|
|
if (fArea) params.set('area', fArea);
|
|
// Origem automática: clínica/consultório ativo (backend cai no geo do usuário se faltar).
|
|
const wsId = HybridBackend.getActiveWorkspace()?.id;
|
|
if (wsId) params.set('clinicaId', wsId);
|
|
if (fCep) params.set('cep', fCep); // CEP digitado sobrepõe a origem automática
|
|
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
|
|
const res = await apiFetch(`/api/salas/marketplace?${params}`);
|
|
if (res.ok) setSalas(await res.json());
|
|
} catch { /* silently fail */ }
|
|
finally { setLoading(false); }
|
|
}, [fTipo, fEstado, fCidade, fCep, fRaio, fArea]);
|
|
|
|
const fetchMinhas = useCallback(async () => {
|
|
setLoading(true);
|
|
try { const res = await apiFetch('/api/salas/minhas'); if (res.ok) setMinhas(await res.json()); }
|
|
catch { /* silently fail */ }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
const fetchReservas = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
// Inclui as reservas da unidade ativa (feitas por qualquer membro), além das pessoais.
|
|
const wsId = HybridBackend.getActiveWorkspace()?.id;
|
|
const res = await apiFetch(`/api/sala-reservas/minhas${wsId ? `?clinicaId=${encodeURIComponent(wsId)}` : ''}`);
|
|
if (res.ok) setReservas(await res.json());
|
|
}
|
|
catch { /* silently fail */ }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (tab === 'marketplace') fetchMarketplace();
|
|
if (tab === 'minhas') fetchMinhas();
|
|
if (tab === 'reservas') fetchReservas();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tab]);
|
|
|
|
const handleDelete = async (sala: Sala) => {
|
|
if (!await confirm(`EXCLUIR A SALA "${sala.nome}"?`)) return;
|
|
try {
|
|
const res = await apiFetch(`/api/salas/${sala.id}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao excluir.');
|
|
toast.success('SALA EXCLUÍDA.');
|
|
// Se a sala excluída era o workspace ativo, sai do contexto dela.
|
|
if (HybridBackend.getActiveWorkspace()?.id === sala.id) { localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE'); await HybridBackend.refreshWorkspaces(); window.location.reload(); return; }
|
|
await HybridBackend.refreshWorkspaces();
|
|
fetchMinhas();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
};
|
|
|
|
const handleReservaAction = async (r: Reserva, acao: 'confirmar' | 'recusar' | 'cancelar') => {
|
|
if (acao === 'cancelar' && !await confirm('CANCELAR ESTA RESERVA?')) return;
|
|
try {
|
|
const res = await apiFetch(`/api/sala-reservas/${r.id}`, { method: 'PUT', body: JSON.stringify({ acao }) });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Erro.');
|
|
toast.success(`RESERVA ${data.status?.toUpperCase()}.`);
|
|
fetchReservas();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
};
|
|
|
|
// Dois modos: 'minhas' (administrar/publicar) e 'alugar' (marketplace). Sem prop = tudo (legado).
|
|
const ALL_TABS: Array<{ id: Tab; label: string; icon: any }> = [
|
|
{ id: 'marketplace', label: 'MARKETPLACE', icon: Search },
|
|
{ id: 'minhas', label: 'MINHAS SALAS', icon: DoorOpen },
|
|
{ id: 'reservas', label: 'RESERVAS', icon: CalendarClock },
|
|
];
|
|
const TABS = view === 'minhas' ? ALL_TABS.filter(t => t.id === 'minhas' || t.id === 'reservas')
|
|
: view === 'alugar' ? ALL_TABS.filter(t => t.id === 'marketplace' || t.id === 'reservas')
|
|
: ALL_TABS;
|
|
|
|
const pendentesRecebidas = reservas.recebidas.filter(r => r.status === 'pendente').length;
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in fade-in duration-500">
|
|
<PageHeader
|
|
title={view === 'minhas' ? 'MINHAS SALAS' : view === 'alugar' ? 'ALUGAR SALAS' : 'LOCAÇÃO DE SALAS'}
|
|
description={view === 'minhas' ? 'Publique e administre suas salas, preços, disponibilidade e reservas recebidas.'
|
|
: view === 'alugar' ? 'Busque e reserve salas equipadas de outros profissionais.'
|
|
: 'Alugue salas equipadas ou disponibilize as suas para outros profissionais.'}
|
|
/>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{TABS.map(t => {
|
|
const Icon = t.icon;
|
|
const active = tab === t.id;
|
|
return (
|
|
<button key={t.id} onClick={() => setTab(t.id)}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`}
|
|
style={active ? { backgroundColor: COLOR } : {}}>
|
|
<Icon size={14} /> {t.label}
|
|
{t.id === 'reservas' && pendentesRecebidas > 0 && (
|
|
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full ${active ? 'bg-white/25' : 'bg-amber-100 text-amber-700'}`}>{pendentesRecebidas}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* MARKETPLACE */}
|
|
{tab === 'marketplace' && enderecoOk === false && (
|
|
<GateEndereco isClinic={!isProfissional} />
|
|
)}
|
|
{tab === 'marketplace' && enderecoOk !== false && (
|
|
<>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
|
|
<div>
|
|
<label className={labelCls}>Área</label>
|
|
<select className={inputCls} value={fArea} onChange={e => setFArea(e.target.value)}>
|
|
<option value="">Todas</option>
|
|
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Tipo</label>
|
|
<select className={inputCls} value={fTipo} onChange={e => setFTipo(e.target.value)}>
|
|
<option value="">Todos</option>
|
|
<option value="clinica">SALA CLÍNICA</option>
|
|
<option value="consultorio">SALA CONSULTÓRIO</option>
|
|
<option value="independente">SALA INDEPENDENTE</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Estado</label>
|
|
<select className={inputCls} value={fEstado} onChange={e => setFEstado(e.target.value)}>
|
|
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos os estados'}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Cidade</label>
|
|
<input className={`${inputCls} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Meu CEP (busca por raio)</label>
|
|
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="00000-000" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Raio (km)</label>
|
|
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="10" />
|
|
</div>
|
|
</div>
|
|
<button onClick={fetchMarketplace} disabled={loading}
|
|
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
|
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} BUSCAR
|
|
</button>
|
|
</div>
|
|
{loading ? (
|
|
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : salas.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-400">
|
|
<DoorOpen size={40} className="mx-auto mb-3 opacity-40" />
|
|
<p className="font-bold text-sm uppercase">Nenhuma sala encontrada</p>
|
|
<p className="text-xs mt-1">Ajuste os filtros ou aguarde novos anúncios</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{salas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* MINHAS SALAS */}
|
|
{tab === 'minhas' && (
|
|
<>
|
|
<button onClick={() => setEditingSala('new')}
|
|
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
|
<Plus size={14} /> CADASTRAR SALA
|
|
</button>
|
|
{loading ? (
|
|
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : minhas.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-400">
|
|
<DoorOpen size={40} className="mx-auto mb-3 opacity-40" />
|
|
<p className="font-bold text-sm uppercase">Você ainda não cadastrou salas</p>
|
|
<p className="text-xs mt-1">Disponibilize uma sala e receba solicitações de reserva</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{minhas.map(s => <SalaCard key={s.id} sala={s} mine onEdit={() => setEditingSala(s)} onDelete={() => handleDelete(s)} />)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* RESERVAS */}
|
|
{tab === 'reservas' && (
|
|
loading ? (
|
|
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : (
|
|
<div className="space-y-8">
|
|
{/* Receita de locação (reservas confirmadas viram recebível no financeiro da sala) */}
|
|
{(() => {
|
|
const conf = reservas.recebidas.filter(r => r.status === 'confirmada' && Number(r.valor) > 0);
|
|
const total = conf.reduce((s, r) => s + Number(r.valor), 0);
|
|
if (conf.length === 0) return null;
|
|
return (
|
|
<div className="bg-teal-50 border border-teal-100 rounded-2xl px-5 py-3 flex items-center justify-between">
|
|
<span className="text-[11px] font-black text-teal-700 uppercase tracking-wide">Receita de locação (confirmadas)</span>
|
|
<span className="text-lg font-black text-teal-700">R$ {total.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}<span className="text-[10px] font-bold text-teal-500 ml-2">{conf.length} reserva(s) → financeiro</span></span>
|
|
</div>
|
|
);
|
|
})()}
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Inbox size={12} /> RECEBIDAS (MINHAS SALAS)</p>
|
|
{reservas.recebidas.length === 0
|
|
? <p className="text-xs text-gray-300 font-bold uppercase">Nenhuma reserva recebida</p>
|
|
: <div className="space-y-3">{reservas.recebidas.map(r => <ReservaRow key={r.id} r={r} recebida onAction={a => handleReservaAction(r, a)} />)}</div>}
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Send size={12} /> FEITAS POR MIM</p>
|
|
{reservas.feitas.length === 0
|
|
? <p className="text-xs text-gray-300 font-bold uppercase">Você ainda não solicitou reservas</p>
|
|
: <div className="space-y-3">{reservas.feitas.map(r => <ReservaRow key={r.id} r={r} onAction={a => handleReservaAction(r, a)} />)}</div>}
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{editingSala && <SalaFormModal sala={editingSala === 'new' ? null : editingSala} onClose={() => setEditingSala(null)} onSaved={fetchMinhas} />}
|
|
{reservingSala && <ReservaModal sala={reservingSala} onClose={() => setReservingSala(null)} onReserved={() => setTab('reservas')} />}
|
|
</div>
|
|
);
|
|
};
|