feat(clinicas): permite criar unidades adicionais (multi-clínica)

- 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>
This commit is contained in:
VPS 4 Builder
2026-06-23 05:46:08 +02:00
parent 0ade8027f5
commit d005fe77a9
2 changed files with 97 additions and 13 deletions
+94 -1
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2 } from 'lucide-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';
@@ -24,9 +24,43 @@ export const ClinicasView: React.FC = () => {
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.");
@@ -81,6 +115,17 @@ export const ClinicasView: React.FC = () => {
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." />
)}
@@ -208,6 +253,54 @@ export const ClinicasView: React.FC = () => {
))}
</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>
);
};
+3 -12
View File
@@ -10,17 +10,6 @@ function getStoredUser(): { nome?: string; role?: string } {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}'); } catch { return {}; }
}
function applyWorkspace(result: any, userRole: string) {
const ws = {
id: result.clinica.id,
nome: result.clinica.nome_fantasia,
cor: '#2563eb',
role: userRole,
};
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify([ws]));
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(ws));
}
export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated }) => {
const storedUser = getStoredUser();
const userRole = storedUser.role || 'donoclinica';
@@ -37,7 +26,9 @@ export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated })
const doCreate = async (nomeFinal: string, doc?: string) => {
const result = await HybridBackend.createClinica(nomeFinal.trim(), doc?.trim() || undefined);
if (!result.success) return result;
applyWorkspace(result, userRole);
// Recarrega a lista do servidor (acrescenta a nova unidade às existentes em vez de
// sobrescrever). No onboarding o usuário tem só esta unidade, então vira a ativa.
await HybridBackend.refreshWorkspaces();
return result;
};