feat(settings): implementa aba sistema e vincula perfil à api
- 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
This commit is contained in:
parent
c71095b5f3
commit
b497ea8c72
4 changed files with 626 additions and 288 deletions
202
frontend/components/System/PriceTableEditor.tsx
Normal file
202
frontend/components/System/PriceTableEditor.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Save, Search, DollarSign } from "lucide-react";
|
||||
import { Button } from "../Button";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
nome: string;
|
||||
}
|
||||
|
||||
interface EventType {
|
||||
id: string;
|
||||
nome: string;
|
||||
}
|
||||
|
||||
interface Price {
|
||||
id?: string;
|
||||
funcao_profissional_id: string;
|
||||
tipo_evento_id: string;
|
||||
valor: number;
|
||||
}
|
||||
|
||||
export const PriceTableEditor: React.FC = () => {
|
||||
const { token } = useAuth();
|
||||
const [eventTypes, setEventTypes] = useState<EventType[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [prices, setPrices] = useState<Record<string, number>>({}); // roleId -> value
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const [eventsRes, rolesRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/tipos-eventos`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch(`${API_BASE_URL}/api/funcoes`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
]);
|
||||
|
||||
const eventsData = await eventsRes.json();
|
||||
const rolesData = await rolesRes.json();
|
||||
|
||||
// Adjust for data wrapper if exists
|
||||
const events = (eventsData.data || eventsData) as EventType[];
|
||||
const roles = (rolesData.data || rolesData) as Role[];
|
||||
|
||||
setEventTypes(events);
|
||||
setRoles(roles);
|
||||
if (events.length > 0) setSelectedEventId(events[0].id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erro ao carregar dados iniciais");
|
||||
}
|
||||
};
|
||||
if (token) loadInitialData();
|
||||
}, [token]);
|
||||
|
||||
// Load Prices when Event Selected
|
||||
useEffect(() => {
|
||||
if (!selectedEventId || !token) return;
|
||||
|
||||
const loadPrices = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Assuming we use type-events/:id/precos or similar
|
||||
// Based on routes: GET /api/tipos-eventos/:id/precos
|
||||
const res = await fetch(`${API_BASE_URL}/api/tipos-eventos/${selectedEventId}/precos`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error("Erro ao carregar preços");
|
||||
|
||||
const data = await res.json();
|
||||
const priceList = (data.data || data) as Price[];
|
||||
|
||||
// Map to dictionary
|
||||
const priceMap: Record<string, number> = {};
|
||||
priceList.forEach(p => {
|
||||
priceMap[p.funcao_profissional_id] = p.valor;
|
||||
});
|
||||
setPrices(priceMap);
|
||||
} catch (error) {
|
||||
toast.error("Erro ao carregar tabela de preços");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadPrices();
|
||||
}, [selectedEventId, token]);
|
||||
|
||||
const handlePriceChange = (roleId: string, value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setPrices(prev => ({ ...prev, [roleId]: numValue }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Need to save each price. Backend has POST /api/tipos-eventos/precos
|
||||
// Body: { tipo_evento_id, funcao_profissional_id, valor }
|
||||
|
||||
// We can do parallel requests or backend bulk?
|
||||
// Existing route seems singular or accepts array?
|
||||
// "api.POST("/tipos-eventos/precos", tiposEventosHandler.SetPrice)"
|
||||
// I'll assume singular for safety, or create a loop.
|
||||
|
||||
const promises = roles.map(role => {
|
||||
const valor = prices[role.id];
|
||||
// If undefined, maybe skip? But maybe we want to save 0?
|
||||
if (valor === undefined) return Promise.resolve();
|
||||
|
||||
return fetch(`${API_BASE_URL}/api/tipos-eventos/precos`, {
|
||||
method: "POST", // or PUT check handlers
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tipo_evento_id: selectedEventId,
|
||||
funcao_profissional_id: role.id,
|
||||
valor: valor
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
toast.success("Preços atualizados com sucesso!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erro ao salvar preços");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6 flex items-center gap-2">
|
||||
<DollarSign className="text-brand-gold" />
|
||||
Tabela de Preços (Cachês Base)
|
||||
</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Selecione o Tipo de Evento</label>
|
||||
<select
|
||||
className="w-full sm:w-1/3 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#492E61] focus:outline-none"
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
>
|
||||
{eventTypes.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.nome}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Função</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Valor do Cachê (R$)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr><td colSpan={2} className="text-center py-4">Carregando...</td></tr>
|
||||
) : roles.map(role => (
|
||||
<tr key={role.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{role.nome}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="relative w-48">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">R$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-purple focus:outline-none"
|
||||
value={prices[role.id] ?? ""} // Use ?? "" to control input
|
||||
onChange={(e) => handlePriceChange(role.id, e.target.value)}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Save size={18} className="mr-2" />
|
||||
{saving ? "Salvando..." : "Salvar Tabela"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
217
frontend/components/System/SimpleCrud.tsx
Normal file
217
frontend/components/System/SimpleCrud.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2, X, Save, Search } from "lucide-react";
|
||||
import { Button } from "../Button";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
interface SimpleCrudProps {
|
||||
title: string;
|
||||
endpoint: string;
|
||||
columns?: { key: string; label: string }[];
|
||||
transformData?: (data: any) => any[];
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
export const SimpleCrud: React.FC<SimpleCrudProps> = ({
|
||||
title,
|
||||
endpoint,
|
||||
columns = [{ key: "nome", label: "Nome" }],
|
||||
transformData
|
||||
}) => {
|
||||
const { token } = useAuth();
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any | null>(null);
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error("Falha ao carregar dados");
|
||||
let data = await res.json();
|
||||
|
||||
// Handle different API response structures if needed
|
||||
if (data.data && Array.isArray(data.data)) data = data.data; // Standard ApiResponse structure
|
||||
|
||||
if (transformData) data = transformData(data);
|
||||
|
||||
setItems(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erro ao carregar lista");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fetchItems();
|
||||
}, [token, endpoint]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Tem certeza que deseja excluir?")) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}${endpoint}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error("Erro ao excluir");
|
||||
toast.success("Item excluído com sucesso");
|
||||
fetchItems();
|
||||
} catch (error) {
|
||||
toast.error("Erro ao excluir item");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const url = editingItem
|
||||
? `${API_BASE_URL}${endpoint}/${editingItem.id}`
|
||||
: `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const method = editingItem ? "PUT" : "POST";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Erro ao salvar");
|
||||
|
||||
toast.success(editingItem ? "Atualizado com sucesso" : "Criado com sucesso");
|
||||
setIsModalOpen(false);
|
||||
fetchItems();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erro ao salvar dados");
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (item?: any) => {
|
||||
setEditingItem(item || null);
|
||||
setFormData(item || {});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
columns.some(col =>
|
||||
String(item[col.key] || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{title}</h2>
|
||||
<div className="flex w-full sm:w-auto gap-2">
|
||||
<div className="relative flex-1 sm:w-64">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#492E61] focus:outline-none"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => openModal()}>
|
||||
<Plus size={18} className="mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr><td colSpan={columns.length + 1} className="text-center py-4">Carregando...</td></tr>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<tr><td colSpan={columns.length + 1} className="text-center py-4 text-gray-500">Nenhum item encontrado.</td></tr>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item[col.key]}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onClick={() => openModal(item)} className="text-indigo-600 hover:text-indigo-900 mr-4">
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(item.id)} className="text-red-600 hover:text-red-900">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{editingItem ? `Editar ${title}` : `Novo ${title}`}
|
||||
</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{columns.map(col => (
|
||||
<div key={col.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{col.label}</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#492E61] focus:outline-none"
|
||||
value={formData[col.key] || ""}
|
||||
onChange={e => setFormData({...formData, [col.key]: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" onClick={() => setIsModalOpen(false)} type="button">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save size={18} className="mr-2" />
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
77
frontend/components/System/SystemSettings.tsx
Normal file
77
frontend/components/System/SystemSettings.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React, { useState } from "react";
|
||||
import { SimpleCrud } from "./SimpleCrud";
|
||||
import { PriceTableEditor } from "./PriceTableEditor";
|
||||
import { Building2, GraduationCap, Calendar, DollarSign, Database } from "lucide-react";
|
||||
|
||||
export const SystemSettings: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<"empresas" | "cursos" | "tipos_evento" | "anos_formatura" | "precos">("empresas");
|
||||
|
||||
const tabs = [
|
||||
{ id: "empresas", label: "Empresas", icon: Building2 },
|
||||
{ id: "cursos", label: "Cursos", icon: GraduationCap },
|
||||
{ id: "tipos_evento", label: "Tipos de Evento", icon: Calendar },
|
||||
{ id: "anos_formatura", label: "Anos de Formatura", icon: Database },
|
||||
{ id: "precos", label: "Tabela de Preços", icon: DollarSign },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-2 overflow-x-auto">
|
||||
<nav className="flex space-x-2">
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`
|
||||
flex items-center px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap
|
||||
${activeTab === tab.id
|
||||
? "bg-brand-gold text-white"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"}
|
||||
`}
|
||||
>
|
||||
<Icon size={18} className="mr-2" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="animate-in fade-in duration-300 slide-in-from-bottom-2">
|
||||
{activeTab === "empresas" && (
|
||||
<SimpleCrud
|
||||
title="Gerenciar Empresas"
|
||||
endpoint="/api/empresas"
|
||||
columns={[{ key: "nome", label: "Nome da Empresa" }]}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "cursos" && (
|
||||
<SimpleCrud
|
||||
title="Gerenciar Cursos"
|
||||
endpoint="/api/cursos"
|
||||
columns={[{ key: "nome", label: "Nome do Curso" }]}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "tipos_evento" && (
|
||||
<SimpleCrud
|
||||
title="Tipos de Evento"
|
||||
endpoint="/api/tipos-eventos"
|
||||
columns={[{ key: "nome", label: "Tipo de Evento" }]}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "anos_formatura" && (
|
||||
<SimpleCrud
|
||||
title="Anos de Formatura"
|
||||
endpoint="/api/anos-formaturas"
|
||||
columns={[{ key: "ano_semestre", label: "Ano/Semestre (Ex: 2024.1)" }]}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "precos" && (
|
||||
<PriceTableEditor />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { SystemSettings } from "../components/System/SystemSettings";
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
|
|
@ -11,7 +12,9 @@ import {
|
|||
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";
|
||||
|
|
@ -22,17 +25,32 @@ export const SettingsPage: React.FC = () => {
|
|||
user?.role === UserRole.SUPERADMIN ||
|
||||
user?.role === UserRole.BUSINESS_OWNER;
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"profile" | "account" | "notifications" | "appearance" | "courses"
|
||||
"profile" | "account" | "notifications" | "appearance" | "system"
|
||||
>("profile");
|
||||
|
||||
const [profileData, setProfileData] = useState({
|
||||
name: "João Silva",
|
||||
email: "joao.silva@photum.com",
|
||||
phone: "(41) 99999-0000",
|
||||
location: "Curitiba, PR",
|
||||
bio: "Fotógrafo profissional especializado em eventos e formaturas há mais de 10 anos.",
|
||||
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,
|
||||
|
|
@ -49,16 +67,83 @@ export const SettingsPage: React.FC = () => {
|
|||
currency: "BRL",
|
||||
});
|
||||
|
||||
const handleSaveProfile = () => {
|
||||
alert("Perfil atualizado com sucesso!");
|
||||
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 = () => {
|
||||
alert("Configurações de notificações salvas!");
|
||||
toast.success("Configurações de notificações salvas!");
|
||||
};
|
||||
|
||||
const handleSaveAppearance = () => {
|
||||
alert("Configurações de aparência salvas!");
|
||||
toast.success("Configurações de aparência salvas!");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -101,28 +186,20 @@ export const SettingsPage: React.FC = () => {
|
|||
<Lock size={18} />
|
||||
<span className="font-medium">Conta</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("notifications")}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors whitespace-nowrap text-sm ${
|
||||
activeTab === "notifications"
|
||||
? "bg-brand-gold text-white"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<span className="font-medium">Notificações</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("appearance")}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors whitespace-nowrap text-sm ${
|
||||
activeTab === "appearance"
|
||||
? "bg-brand-gold text-white"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Palette size={18} />
|
||||
<span className="font-medium">Aparência</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>
|
||||
|
|
@ -153,28 +230,21 @@ export const SettingsPage: React.FC = () => {
|
|||
<Lock size={20} />
|
||||
<span className="font-medium">Conta</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("notifications")}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${
|
||||
activeTab === "notifications"
|
||||
? "bg-brand-gold text-white"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Bell size={20} />
|
||||
<span className="font-medium">Notificações</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("appearance")}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors ${
|
||||
activeTab === "appearance"
|
||||
? "bg-brand-gold text-white"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Palette size={20} />
|
||||
<span className="font-medium">Aparência</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>
|
||||
|
|
@ -402,242 +472,14 @@ export const SettingsPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === "notifications" && (
|
||||
|
||||
{/* System Tab */}
|
||||
{activeTab === "system" && isAdmin && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">
|
||||
Preferências de Notificações
|
||||
<h2 className="text-2xl font-semibold mb-6">
|
||||
Configurações do Sistema
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Notificações por Email</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Receba atualizações por email
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.emailNotifications}
|
||||
onChange={(e) =>
|
||||
setNotificationSettings({
|
||||
...notificationSettings,
|
||||
emailNotifications: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Notificações Push</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Receba notificações no navegador
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.pushNotifications}
|
||||
onChange={(e) =>
|
||||
setNotificationSettings({
|
||||
...notificationSettings,
|
||||
pushNotifications: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">SMS</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Receba mensagens de texto
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.smsNotifications}
|
||||
onChange={(e) =>
|
||||
setNotificationSettings({
|
||||
...notificationSettings,
|
||||
smsNotifications: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Lembretes de Eventos</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Receba lembretes antes dos eventos
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.eventReminders}
|
||||
onChange={(e) =>
|
||||
setNotificationSettings({
|
||||
...notificationSettings,
|
||||
eventReminders: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-medium">Alertas de Pagamento</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Notificações sobre pagamentos
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.paymentAlerts}
|
||||
onChange={(e) =>
|
||||
setNotificationSettings({
|
||||
...notificationSettings,
|
||||
paymentAlerts: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-gold/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-gold"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={handleSaveNotifications}
|
||||
>
|
||||
<Save size={20} className="mr-2" />
|
||||
Salvar Preferências
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appearance Tab */}
|
||||
{activeTab === "appearance" && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">
|
||||
Aparência e Idioma
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tema
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.theme}
|
||||
onChange={(e) =>
|
||||
setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
theme: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="light">Claro</option>
|
||||
<option value="dark">Escuro</option>
|
||||
<option value="auto">Automático</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.language}
|
||||
onChange={(e) =>
|
||||
setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
language: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="pt-BR">Português (Brasil)</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="es-ES">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Formato de Data
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.dateFormat}
|
||||
onChange={(e) =>
|
||||
setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
dateFormat: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
|
||||
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
|
||||
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Moeda
|
||||
</label>
|
||||
<select
|
||||
value={appearanceSettings.currency}
|
||||
onChange={(e) =>
|
||||
setAppearanceSettings({
|
||||
...appearanceSettings,
|
||||
currency: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
>
|
||||
<option value="BRL">Real (R$)</option>
|
||||
<option value="USD">Dólar ($)</option>
|
||||
<option value="EUR">Euro (€)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={handleSaveAppearance}
|
||||
>
|
||||
<Save size={20} className="mr-2" />
|
||||
Salvar Configurações
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SystemSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue