- 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
217 lines
7.9 KiB
TypeScript
217 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
};
|