- adiciona aba 'Sistema' nas configurações para gestão de tabelas auxiliares (CRUD) e tabela de preços - vincula formulário de perfil com dados do usuário logado (API /api/profissionais) - oculta abas 'Notificações' e 'Aparência' - corrige layout e bugs de estado na página de configurações
491 lines
19 KiB
TypeScript
491 lines
19 KiB
TypeScript
import React, { useState } from "react";
|
|
import { SystemSettings } from "../components/System/SystemSettings";
|
|
import {
|
|
User,
|
|
Mail,
|
|
Phone,
|
|
MapPin,
|
|
Lock,
|
|
Bell,
|
|
Palette,
|
|
Globe,
|
|
Save,
|
|
Camera,
|
|
GraduationCap,
|
|
Database,
|
|
} from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
import { Button } from "../components/Button";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { UserRole } from "../types";
|
|
|
|
export const SettingsPage: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const isAdmin =
|
|
user?.role === UserRole.SUPERADMIN ||
|
|
user?.role === UserRole.BUSINESS_OWNER;
|
|
const [activeTab, setActiveTab] = useState<
|
|
"profile" | "account" | "notifications" | "appearance" | "system"
|
|
>("profile");
|
|
|
|
const [profileData, setProfileData] = useState({
|
|
name: "",
|
|
email: "",
|
|
phone: "",
|
|
location: "",
|
|
bio: "",
|
|
avatar: "https://i.pravatar.cc/150?img=68",
|
|
});
|
|
|
|
// Effect to sync state with user data
|
|
React.useEffect(() => {
|
|
if (user) {
|
|
setProfileData({
|
|
name: user.name || "",
|
|
email: user.email || "",
|
|
phone: user.phone || "",
|
|
location: user.profissional?.cidade || "",
|
|
bio: user.profissional?.observacao || "",
|
|
avatar: user.profissional?.avatar_url || "https://i.pravatar.cc/150?img=68",
|
|
});
|
|
}
|
|
}, [user]);
|
|
|
|
const [notificationSettings, setNotificationSettings] = useState({
|
|
emailNotifications: true,
|
|
pushNotifications: true,
|
|
smsNotifications: false,
|
|
eventReminders: true,
|
|
paymentAlerts: true,
|
|
teamUpdates: false,
|
|
});
|
|
|
|
const [appearanceSettings, setAppearanceSettings] = useState({
|
|
theme: "light",
|
|
language: "pt-BR",
|
|
dateFormat: "DD/MM/YYYY",
|
|
currency: "BRL",
|
|
});
|
|
|
|
const handleSaveProfile = async () => {
|
|
try {
|
|
if (!user?.profissional?.id) {
|
|
toast.error("Perfil profissional não encontrado para atualização.");
|
|
return;
|
|
}
|
|
|
|
const token = localStorage.getItem("token") || document.cookie.replace(/(?:(?:^|.*;\s*)access_token\s*\=\s*([^;]*).*$)|^.*$/, "$1");
|
|
|
|
// Prepare payload for Professional Update
|
|
// Note: The backend expects specific fields. We map what we have.
|
|
const payload = {
|
|
nome: profileData.name,
|
|
email: profileData.email, // Note: Prof service might not update email if it doesn't match User table sync logic, but we send it.
|
|
whatsapp: profileData.phone,
|
|
cidade: profileData.location,
|
|
observacao: profileData.bio,
|
|
avatar_url: profileData.avatar,
|
|
// Maintain other required fields if necessary, but PUT usually allows partial or defaults?
|
|
// Checking backend: It uses 'toPgText', so missing fields become NULL?
|
|
// We should fetch existing professional data first to merge?
|
|
// Or assume the backend handles partial updates?
|
|
// The backend implementation 'CreateProfissionalInput' struct in Update assumes REPLACING values.
|
|
// Be careful. ideally we should GET /me/profissional first.
|
|
// But we have 'user.profissional' which might be incomplete.
|
|
// Let's rely on what we have.
|
|
};
|
|
|
|
// Fetch current professional data to avoid overwriting with nulls
|
|
const responseGet = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/${user.profissional.id}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
let currentData = {};
|
|
if (responseGet.ok) {
|
|
currentData = await responseGet.json();
|
|
}
|
|
|
|
const finalPayload = {
|
|
...currentData, // Merge existing
|
|
nome: profileData.name,
|
|
whatsapp: profileData.phone,
|
|
cidade: profileData.location,
|
|
observacao: profileData.bio,
|
|
// email is usually read-only or handled separately in generic updates
|
|
};
|
|
|
|
const response = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/${user.profissional.id}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify(finalPayload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Falha ao atualizar perfil");
|
|
}
|
|
|
|
toast.success("Perfil atualizado com sucesso!");
|
|
// Optionally refresh user context
|
|
window.location.reload(); // Simple way to refresh context
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Erro ao salvar perfil.");
|
|
}
|
|
};
|
|
|
|
const handleSaveNotifications = () => {
|
|
toast.success("Configurações de notificações salvas!");
|
|
};
|
|
|
|
const handleSaveAppearance = () => {
|
|
toast.success("Configurações de aparência salvas!");
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
{/* Header */}
|
|
<div className="mb-6 sm:mb-8">
|
|
<h1 className="text-2xl sm:text-3xl font-serif font-bold text-brand-black mb-2">
|
|
Configurações
|
|
</h1>
|
|
<p className="text-sm sm:text-base text-gray-600">
|
|
Gerencie suas preferências e informações da conta
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 sm:gap-6">
|
|
{/* Mobile Tabs - Horizontal */}
|
|
<div className="lg:hidden">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-2">
|
|
<nav className="flex overflow-x-auto gap-1 scrollbar-hide">
|
|
<button
|
|
onClick={() => setActiveTab("profile")}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors whitespace-nowrap text-sm ${
|
|
activeTab === "profile"
|
|
? "bg-brand-gold text-white"
|
|
: "text-gray-700 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<User size={18} />
|
|
<span className="font-medium">Perfil</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("account")}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors whitespace-nowrap text-sm ${
|
|
activeTab === "account"
|
|
? "bg-brand-gold text-white"
|
|
: "text-gray-700 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<Lock size={18} />
|
|
<span className="font-medium">Conta</span>
|
|
</button>
|
|
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => setActiveTab("system")}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors whitespace-nowrap text-sm ${
|
|
activeTab === "system"
|
|
? "bg-brand-gold text-white"
|
|
: "text-gray-700 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<Database size={18} />
|
|
<span className="font-medium">Sistema</span>
|
|
</button>
|
|
)}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop Sidebar - Vertical */}
|
|
<div className="hidden lg:block lg:col-span-1">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
<nav className="space-y-1">
|
|
<button
|
|
onClick={() => setActiveTab("profile")}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${
|
|
activeTab === "profile"
|
|
? "bg-brand-gold text-white"
|
|
: "text-gray-700 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<User size={20} />
|
|
<span className="font-medium">Perfil</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("account")}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${
|
|
activeTab === "account"
|
|
? "bg-brand-gold text-white"
|
|
: "text-gray-700 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<Lock size={20} />
|
|
<span className="font-medium">Conta</span>
|
|
</button>
|
|
|
|
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => setActiveTab("system")}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${
|
|
activeTab === "system"
|
|
? "bg-brand-gold text-white"
|
|
: "text-gray-700 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<Database size={20} />
|
|
<span className="font-medium">Sistema</span>
|
|
</button>
|
|
)}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="lg:col-span-3">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 sm:p-6 md:p-8">
|
|
{/* Profile Tab */}
|
|
{activeTab === "profile" && (
|
|
<div>
|
|
<h2 className="text-xl sm:text-2xl font-semibold mb-4 sm:mb-6">
|
|
Informações do Perfil
|
|
</h2>
|
|
|
|
<div className="mb-6 sm:mb-8">
|
|
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
|
|
<div className="relative">
|
|
<img
|
|
src={profileData.avatar}
|
|
alt="Avatar"
|
|
className="w-20 h-20 sm:w-24 sm:h-24 rounded-full object-cover"
|
|
/>
|
|
<button className="absolute bottom-0 right-0 w-7 h-7 sm:w-8 sm:h-8 bg-brand-gold text-white rounded-full flex items-center justify-center hover:bg-[#a5bd2e] transition-colors">
|
|
<Camera size={14} className="sm:w-4 sm:h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="text-center sm:text-left">
|
|
<h3 className="font-semibold text-base sm:text-lg">
|
|
{profileData.name}
|
|
</h3>
|
|
<p className="text-xs sm:text-sm text-gray-600">
|
|
{profileData.email}
|
|
</p>
|
|
<button className="text-xs sm:text-sm text-brand-gold hover:underline mt-1">
|
|
Alterar foto
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Nome Completo
|
|
</label>
|
|
<div className="relative">
|
|
<User
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
|
size={20}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={profileData.name}
|
|
onChange={(e) =>
|
|
setProfileData({
|
|
...profileData,
|
|
name: e.target.value,
|
|
})
|
|
}
|
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Email
|
|
</label>
|
|
<div className="relative">
|
|
<Mail
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
|
size={20}
|
|
/>
|
|
<input
|
|
type="email"
|
|
value={profileData.email}
|
|
onChange={(e) =>
|
|
setProfileData({
|
|
...profileData,
|
|
email: e.target.value,
|
|
})
|
|
}
|
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Telefone
|
|
</label>
|
|
<div className="relative">
|
|
<Phone
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
|
size={20}
|
|
/>
|
|
<input
|
|
type="tel"
|
|
value={profileData.phone}
|
|
onChange={(e) =>
|
|
setProfileData({
|
|
...profileData,
|
|
phone: e.target.value,
|
|
})
|
|
}
|
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Localização
|
|
</label>
|
|
<div className="relative">
|
|
<MapPin
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
|
size={20}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={profileData.location}
|
|
onChange={(e) =>
|
|
setProfileData({
|
|
...profileData,
|
|
location: e.target.value,
|
|
})
|
|
}
|
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Biografia
|
|
</label>
|
|
<textarea
|
|
value={profileData.bio}
|
|
onChange={(e) =>
|
|
setProfileData({
|
|
...profileData,
|
|
bio: e.target.value,
|
|
})
|
|
}
|
|
rows={4}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<Button
|
|
size="lg"
|
|
variant="secondary"
|
|
onClick={handleSaveProfile}
|
|
>
|
|
<Save size={20} className="mr-2" />
|
|
Salvar Alterações
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Account Tab */}
|
|
{activeTab === "account" && (
|
|
<div>
|
|
<h2 className="text-2xl font-semibold mb-6">
|
|
Segurança da Conta
|
|
</h2>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Senha Atual
|
|
</label>
|
|
<input
|
|
type="password"
|
|
placeholder="Digite sua senha atual"
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Nova Senha
|
|
</label>
|
|
<input
|
|
type="password"
|
|
placeholder="Digite sua nova senha"
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Confirmar Nova Senha
|
|
</label>
|
|
<input
|
|
type="password"
|
|
placeholder="Confirme sua nova senha"
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<Button size="lg" variant="secondary">
|
|
<Lock size={20} className="mr-2" />
|
|
Atualizar Senha
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="pt-8 border-t border-gray-200">
|
|
<h3 className="text-lg font-semibold mb-4">
|
|
Autenticação em Dois Fatores
|
|
</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
Adicione uma camada extra de segurança à sua conta
|
|
</p>
|
|
<Button size="md" variant="outline">
|
|
Ativar 2FA
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* System Tab */}
|
|
{activeTab === "system" && isAdmin && (
|
|
<div>
|
|
<h2 className="text-2xl font-semibold mb-6">
|
|
Configurações do Sistema
|
|
</h2>
|
|
<SystemSettings />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|