d005fe77a9
- ClinicasView: botão '+ Nova clínica/consultório' + modal de criação, gateado pelo papel da conta (donoclinica/donoconsultorio/admin). - Corrige applyWorkspace destrutivo no onboarding: usa refreshWorkspaces (acrescenta a unidade à lista, em vez de sobrescrever as existentes). Antes não havia caminho de UI para criar uma 2ª clínica fora do onboarding; o backend já suportava N clínicas. Validado em DEV (8020). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
307 lines
18 KiB
TypeScript
307 lines
18 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2, Plus, X } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
import { EmptyState } from '../components/EmptyState.tsx';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
|
|
// Cores profissionais permitidas
|
|
const PRESET_COLORS = [
|
|
{ name: 'Azul Score', value: '#2563eb' },
|
|
{ name: 'Verde Esmeralda', value: '#059669' },
|
|
{ name: 'Indigo Premium', value: '#4f46e5' },
|
|
{ name: 'Vinho Elegante', value: '#9f1239' },
|
|
{ name: 'Slate Moderno', value: '#334155' },
|
|
{ name: 'Laranja Vibrante', value: '#ea580c' },
|
|
{ name: 'Teal Clínico', value: '#0d9488' },
|
|
{ name: 'Roxo Nobre', value: '#7c3aed' },
|
|
];
|
|
|
|
export const ClinicasView: React.FC = () => {
|
|
const workspaces = HybridBackend.getWorkspaces();
|
|
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
|
const toast = useToast();
|
|
const currentRole = HybridBackend.getCurrentRole();
|
|
const isAdmin = currentRole === 'admin' || currentRole === 'donoclinica';
|
|
|
|
// Papel da CONTA (não da unidade ativa) — é o que o backend usa para decidir
|
|
// se a nova unidade nasce como 'clinica' ou 'consultorio'.
|
|
const accountRole = (() => {
|
|
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').role as string | undefined; }
|
|
catch { return undefined; }
|
|
})();
|
|
const isConsultorioAccount = accountRole === 'donoconsultorio';
|
|
const unitLabel = isConsultorioAccount ? 'CONSULTÓRIO' : 'CLÍNICA';
|
|
const canCreate = ['donoclinica', 'donoconsultorio', 'admin'].includes(accountRole || '');
|
|
|
|
const [isEditingColor, setIsEditingColor] = useState<string | null>(null);
|
|
const [savingColor, setSavingColor] = useState(false);
|
|
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [newNome, setNewNome] = useState('');
|
|
const [newDoc, setNewDoc] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const handleCreate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newNome.trim()) return;
|
|
setCreating(true);
|
|
try {
|
|
const result = await HybridBackend.createClinica(newNome.trim(), newDoc.trim() || undefined);
|
|
if (!result.success) { toast.error(result.message || 'ERRO AO CRIAR UNIDADE.'); return; }
|
|
// Recarrega a lista COMPLETA do servidor (acrescenta a nova unidade às existentes,
|
|
// em vez de sobrescrever). A tela relê os workspaces no reload.
|
|
await HybridBackend.refreshWorkspaces();
|
|
toast.success(`${unitLabel} CRIADA!`);
|
|
setTimeout(() => window.location.reload(), 900);
|
|
} catch {
|
|
toast.error('ERRO DE CONEXÃO.');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleSwitch = (workspaceId: string) => {
|
|
if (activeWorkspace?.id === workspaceId) {
|
|
toast.info("VOCÊ JÁ ESTÁ NESTA UNIDADE.");
|
|
return;
|
|
}
|
|
HybridBackend.switchWorkspace(workspaceId);
|
|
toast.success("TROCANDO DE UNIDADE...");
|
|
};
|
|
|
|
const handleUpdateColor = async (workspaceId: string, color: string) => {
|
|
setSavingColor(true);
|
|
try {
|
|
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
|
const res = await fetch(`/api/clinicas/${workspaceId}/color`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
|
},
|
|
body: JSON.stringify({ cor: color })
|
|
});
|
|
|
|
if (res.ok) {
|
|
toast.success("COR DA UNIDADE ATUALIZADA!");
|
|
|
|
// Atualizar o localStorage local para refletir a mudança sem refresh imediato no objeto workspaces
|
|
const updatedWorkspaces = workspaces.map(w => w.id === workspaceId ? { ...w, cor: color } : w);
|
|
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify(updatedWorkspaces));
|
|
|
|
// Se a clínica editada for a ativa, atualizamos ela também
|
|
if (activeWorkspace?.id === workspaceId) {
|
|
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify({ ...activeWorkspace, cor: color }));
|
|
}
|
|
|
|
setIsEditingColor(null);
|
|
// Forçamos um reload após um pequeno delay para aplicar as cores globalmente via CSS/Variables se necessário
|
|
setTimeout(() => window.location.reload(), 1000);
|
|
} else {
|
|
toast.error("ERRO AO SALVAR COR.");
|
|
}
|
|
} catch (err) {
|
|
toast.error("ERRO DE CONEXÃO.");
|
|
} finally {
|
|
setSavingColor(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-10 animate-in fade-in duration-500">
|
|
<PageHeader
|
|
title="GESTÃO DE UNIDADES"
|
|
description="Configure a identidade visual e selecione a clínica para gerenciar atendimentos."
|
|
/>
|
|
|
|
{canCreate && (
|
|
<div className="flex justify-end -mt-4">
|
|
<button
|
|
onClick={() => { setNewNome(''); setNewDoc(''); setShowCreate(true); }}
|
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-2xl bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-widest shadow-lg transition-colors"
|
|
>
|
|
<Plus size={16} /> Nova {unitLabel.toLowerCase()}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{workspaces.length === 0 && (
|
|
<EmptyState icon={Building2} title="Nenhuma unidade ainda" description="Você ainda não tem clínicas ou consultórios vinculados a esta conta." />
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
{workspaces.map((ws) => {
|
|
const isActive = activeWorkspace?.id === ws.id;
|
|
const clinColor = ws.cor || '#2563eb';
|
|
|
|
return (
|
|
<div
|
|
key={ws.id}
|
|
className={`group relative bg-white rounded-[2.5rem] p-1 border-2 transition-all duration-500 ${isActive
|
|
? 'shadow-[0_20px_50px_-15px_rgba(0,0,0,0.1)]'
|
|
: 'border-transparent hover:border-gray-100 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)]'
|
|
}`}
|
|
style={{ borderColor: isActive ? clinColor : 'transparent' }}
|
|
>
|
|
<div className="px-8 py-10 rounded-[2.2rem] bg-white h-full flex flex-col">
|
|
<div className="flex justify-between items-start mb-8">
|
|
<div
|
|
className={`w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-500 group-hover:scale-110 shadow-lg`}
|
|
style={{
|
|
backgroundColor: clinColor,
|
|
color: '#fff',
|
|
boxShadow: `0 10px 20px -5px ${clinColor}44`
|
|
}}
|
|
>
|
|
<Building2 size={32} />
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-2">
|
|
{isActive && (
|
|
<span
|
|
className="text-white text-[9px] font-black px-4 py-1.5 rounded-full uppercase tracking-widest shadow-sm"
|
|
style={{ backgroundColor: clinColor }}
|
|
>
|
|
ATIVA
|
|
</span>
|
|
)}
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => setIsEditingColor(isEditingColor === ws.id ? null : ws.id)}
|
|
className="p-2 text-gray-400 hover:text-gray-600 bg-gray-50 rounded-xl transition-colors"
|
|
title="Mudar Cor"
|
|
>
|
|
<Palette size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 flex-grow">
|
|
<h3 className="text-2xl font-black text-gray-900 uppercase tracking-tighter leading-tight">
|
|
{ws.nome}
|
|
</h3>
|
|
|
|
{isEditingColor === ws.id ? (
|
|
<div className="mt-4 p-4 bg-gray-50 rounded-2xl border border-gray-100 animate-in slide-in-from-top-2 duration-300">
|
|
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest mb-3">Escolha a cor da marca</p>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{PRESET_COLORS.map(c => (
|
|
<button
|
|
key={c.value}
|
|
onClick={() => handleUpdateColor(ws.id, c.value)}
|
|
disabled={savingColor}
|
|
className={`w-full h-8 rounded-lg border-2 transition-all ${ws.cor === c.value ? 'border-white ring-2 ring-gray-300' : 'border-transparent hover:scale-110'}`}
|
|
style={{ backgroundColor: c.value }}
|
|
title={c.name}
|
|
>
|
|
{savingColor && ws.cor === c.value && <Loader2 size={12} className="animate-spin text-white mx-auto" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2 text-gray-400">
|
|
<MapPin size={14} />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest">Unidade Operacional</span>
|
|
</div>
|
|
|
|
<div className="mt-6 pt-6 border-t border-gray-50 flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: `${clinColor}11`, color: clinColor }}>
|
|
<Shield size={16} />
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest leading-none mb-1">Seu Perfil</p>
|
|
<p className="text-xs font-bold text-gray-700 uppercase">{ws.role}</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => handleSwitch(ws.id)}
|
|
disabled={isActive || isEditingColor === ws.id}
|
|
className={`mt-10 w-full py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all duration-300 flex items-center justify-center gap-3 group/btn shadow-lg`}
|
|
style={{
|
|
backgroundColor: isActive ? '#f3f4f6' : clinColor,
|
|
color: isActive ? '#9ca3af' : '#fff',
|
|
boxShadow: isActive ? 'none' : `0 10px 20px -10px ${clinColor}66`
|
|
}}
|
|
>
|
|
{isActive ? (
|
|
<>UNIDADE ATUAL <BadgeCheck size={18} /></>
|
|
) : (
|
|
<>ACESSAR UNIDADE <ArrowRight size={18} className="group-hover/btn:translate-x-1 transition-transform" /></>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="bg-white rounded-[2rem] p-10 border border-gray-100 flex flex-col md:flex-row items-center justify-between gap-6 shadow-sm">
|
|
<div className="text-center md:text-left">
|
|
<h4 className="text-lg font-black text-gray-900 uppercase tracking-tight italic">Identidade Visual Dinâmica</h4>
|
|
<p className="text-sm text-gray-500 font-medium">A cor escolhida será aplicada automaticamente em toda a interface desta clínica.</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{PRESET_COLORS.slice(0, 4).map(c => (
|
|
<div key={c.value} className="w-3 h-3 rounded-full" style={{ backgroundColor: c.value }}></div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<div
|
|
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex items-center justify-center p-4"
|
|
onClick={() => { if (!creating) setShowCreate(false); }}
|
|
>
|
|
<div className="bg-white rounded-3xl w-full max-w-md p-8 shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="text-xl font-black text-gray-900 uppercase tracking-tight">Nova {unitLabel.toLowerCase()}</h3>
|
|
<button
|
|
onClick={() => { if (!creating) setShowCreate(false); }}
|
|
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">Nome do {unitLabel.toLowerCase()} *</label>
|
|
<input
|
|
autoFocus
|
|
required
|
|
value={newNome}
|
|
onChange={e => setNewNome(e.target.value.toUpperCase())}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium uppercase"
|
|
placeholder={isConsultorioAccount ? 'EX: DR. SILVA - CONSULTÓRIO' : 'EX: CLÍNICA SORRISO'}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">CNPJ / CPF (opcional)</label>
|
|
<input
|
|
value={newDoc}
|
|
onChange={e => setNewDoc(e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium"
|
|
placeholder="00.000.000/0000-00"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={creating || !newNome.trim()}
|
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white py-3 rounded-xl font-black uppercase flex items-center justify-center gap-2 transition-all shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
|
>
|
|
{creating ? <><Loader2 className="animate-spin" size={18} /> Criando...</> : <><Plus size={18} /> Criar {unitLabel.toLowerCase()}</>}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|