feat: Refatoração da página de Perfil com novo layout Sidebar e correções de Header e Backend
This commit is contained in:
parent
d72b492882
commit
c71095b5f3
8 changed files with 624 additions and 161 deletions
|
|
@ -163,6 +163,7 @@ func main() {
|
|||
{
|
||||
profGroup.POST("", profissionaisHandler.Create)
|
||||
profGroup.GET("", profissionaisHandler.List)
|
||||
profGroup.GET("/me", profissionaisHandler.Me)
|
||||
profGroup.GET("/:id", profissionaisHandler.Get)
|
||||
profGroup.PUT("/:id", profissionaisHandler.Update)
|
||||
profGroup.DELETE("/:id", profissionaisHandler.Delete)
|
||||
|
|
|
|||
|
|
@ -159,6 +159,38 @@ func toResponse(p interface{}) ProfissionalResponse {
|
|||
Email: fromPgText(v.Email),
|
||||
AvatarURL: fromPgText(v.AvatarUrl),
|
||||
}
|
||||
case generated.GetProfissionalByUsuarioIDRow:
|
||||
return ProfissionalResponse{
|
||||
ID: uuid.UUID(v.ID.Bytes).String(),
|
||||
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
|
||||
Nome: v.Nome,
|
||||
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
|
||||
FuncaoProfissional: "",
|
||||
Functions: toJSONRaw(v.Functions),
|
||||
Endereco: fromPgText(v.Endereco),
|
||||
Cidade: fromPgText(v.Cidade),
|
||||
Uf: fromPgText(v.Uf),
|
||||
Whatsapp: fromPgText(v.Whatsapp),
|
||||
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
|
||||
Banco: fromPgText(v.Banco),
|
||||
Agencia: fromPgText(v.Agencia),
|
||||
ContaPix: fromPgText(v.ContaPix),
|
||||
CarroDisponivel: fromPgBool(v.CarroDisponivel),
|
||||
TemEstudio: fromPgBool(v.TemEstudio),
|
||||
QtdEstudio: fromPgInt4(v.QtdEstudio),
|
||||
TipoCartao: fromPgText(v.TipoCartao),
|
||||
Observacao: fromPgText(v.Observacao),
|
||||
QualTec: fromPgInt4(v.QualTec),
|
||||
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
|
||||
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
|
||||
DispHorario: fromPgInt4(v.DispHorario),
|
||||
Media: fromPgNumeric(v.Media),
|
||||
TabelaFree: fromPgText(v.TabelaFree),
|
||||
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
|
||||
Equipamentos: fromPgText(v.Equipamentos),
|
||||
Email: fromPgText(v.Email),
|
||||
AvatarURL: fromPgText(v.AvatarUrl),
|
||||
}
|
||||
default:
|
||||
return ProfissionalResponse{}
|
||||
}
|
||||
|
|
@ -298,6 +330,47 @@ func (h *Handler) List(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get current authenticated profissional
|
||||
// @Description Get the profissional profile associated with the current user
|
||||
// @Tags profissionais
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} ProfissionalResponse
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/profissionais/me [get]
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
userIDStr, ok := userID.(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id type"})
|
||||
return
|
||||
}
|
||||
|
||||
prof, err := h.service.GetByUserID(c.Request.Context(), userIDStr)
|
||||
if err != nil {
|
||||
// Checks if error is "no rows in result set" -> 404
|
||||
if err.Error() == "no rows in result set" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "professional profile not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse toResponse which handles different types via interface{} check
|
||||
// We need to add case for GetProfissionalByUsuarioIDRow in toResponse function
|
||||
c.JSON(http.StatusOK, toResponse(*prof))
|
||||
}
|
||||
|
||||
// Get godoc
|
||||
// @Summary Get profissional by ID
|
||||
// @Description Get a profissional by ID
|
||||
|
|
|
|||
|
|
@ -246,6 +246,20 @@ func (s *Service) Update(ctx context.Context, id string, input UpdateProfissiona
|
|||
return &prof, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetByUserID(ctx context.Context, userID string) (*generated.GetProfissionalByUsuarioIDRow, error) {
|
||||
uuidVal, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid user id")
|
||||
}
|
||||
|
||||
prof, err := s.queries.GetProfissionalByUsuarioID(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &prof, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
fmt.Printf("[DEBUG] Deleting Professional: %s\n", id)
|
||||
uuidVal, err := uuid.Parse(id)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { Button } from "./components/Button";
|
|||
import { X } from "lucide-react";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import ProfessionalStatement from "./pages/ProfessionalStatement";
|
||||
import { ProfilePage } from "./pages/Profile";
|
||||
|
||||
// Componente de acesso negado
|
||||
const AccessDenied: React.FC = () => {
|
||||
|
|
@ -740,6 +741,15 @@ const AppContent: React.FC = () => {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Rota padrão - redireciona para home */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<nav className="fixed w-full z-50 bg-white shadow-sm py-2 sm:py-3">
|
||||
<nav className="fixed top-0 left-0 w-full z-50 bg-white shadow-sm py-2 sm:py-3">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-4 lg:px-6">
|
||||
<div className="flex justify-between items-center h-12 sm:h-14 md:h-16">
|
||||
|
||||
|
|
@ -242,7 +242,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
user.role === UserRole.EVENT_OWNER) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditProfileModalOpen(true);
|
||||
onNavigate("profile");
|
||||
setIsAccountDropdownOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white transition-colors text-left group"
|
||||
|
|
@ -619,7 +619,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
user.role === UserRole.EVENT_OWNER) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditProfileModalOpen(true);
|
||||
onNavigate("profile");
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 bg-[#492E61]/5 hover:bg-[#492E61]/10 rounded-xl transition-colors"
|
||||
|
|
@ -690,158 +690,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
)}
|
||||
</nav>
|
||||
|
||||
{/* Modal de Edição de Perfil - Apenas para Fotógrafos e Clientes */}
|
||||
{isEditProfileModalOpen &&
|
||||
(user?.role === UserRole.PHOTOGRAPHER ||
|
||||
user?.role === UserRole.EVENT_OWNER) && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100] p-4 fade-in"
|
||||
onClick={() => setIsEditProfileModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-[#492E61] to-[#5a3a7a] p-6 sm:p-8 text-center relative">
|
||||
<button
|
||||
onClick={() => setIsEditProfileModalOpen(false)}
|
||||
className="absolute top-4 right-4 text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<div className="w-24 h-24 mx-auto mb-4 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center border-4 border-white/30 overflow-hidden relative group">
|
||||
<img
|
||||
src={avatarPreview || getAvatarSrc(profileData)}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<Camera size={28} className="text-white" />
|
||||
</label>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">
|
||||
Editar Perfil
|
||||
</h2>
|
||||
<p className="text-white/80 text-sm">
|
||||
Atualize suas informações pessoais
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
className="p-6 sm:p-8 space-y-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
// Aqui você pode adicionar a lógica para salvar os dados
|
||||
alert("Perfil atualizado com sucesso!");
|
||||
setIsEditProfileModalOpen(false);
|
||||
}}
|
||||
>
|
||||
{/* Nome Completo */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nome Completo
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User
|
||||
size={20}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.name}
|
||||
onChange={(e) =>
|
||||
setProfileData({ ...profileData, name: e.target.value })
|
||||
}
|
||||
className="w-full pl-11 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#492E61] focus:border-transparent transition-all"
|
||||
placeholder="Seu nome completo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
size={20}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={profileData.email}
|
||||
onChange={(e) =>
|
||||
setProfileData({
|
||||
...profileData,
|
||||
email: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full pl-11 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#492E61] focus:border-transparent transition-all"
|
||||
placeholder="seu@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Telefone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefone
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone
|
||||
size={20}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
value={profileData.phone}
|
||||
onChange={(e) =>
|
||||
setProfileData({
|
||||
...profileData,
|
||||
phone: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full pl-11 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#492E61] focus:border-transparent transition-all"
|
||||
placeholder="(00) 00000-0000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditProfileModalOpen(false)}
|
||||
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-colors font-medium"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 bg-[#492E61] text-white rounded-xl hover:bg-[#3a2450] transition-colors font-medium shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
40
frontend/package-lock.json
generated
40
frontend/package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
|||
"mapbox-gl": "^3.16.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -54,7 +55,6 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -1301,7 +1301,6 @@
|
|||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -1449,7 +1448,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
|
|
@ -1555,6 +1553,12 @@
|
|||
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
|
|
@ -1828,6 +1832,15 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.18",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
|
||||
|
|
@ -2223,7 +2236,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2283,7 +2295,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -2293,7 +2304,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -2301,6 +2311,23 @@
|
|||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.3",
|
||||
"goober": "^2.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
|
|
@ -2707,7 +2734,6 @@
|
|||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"mapbox-gl": "^3.16.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
489
frontend/pages/Profile.tsx
Normal file
489
frontend/pages/Profile.tsx
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
User, Mail, Phone, MapPin, DollarSign, Briefcase,
|
||||
Camera, FileText, Check, CreditCard, Save, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { Navbar } from "../components/Navbar";
|
||||
import { Button } from "../components/Button";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { getFunctions } from "../services/apiService";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
// --- Helper Components ---
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string;
|
||||
icon?: any;
|
||||
value?: any;
|
||||
className?: string;
|
||||
onChange?: (e: any) => void;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const InputField = ({ label, icon: Icon, className, ...props }: InputFieldProps) => (
|
||||
<div className={className}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{label}</label>
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className={`w-full ${Icon ? 'pl-10' : 'pl-4'} pr-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#492E61] focus:border-transparent transition-all`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Toggle = ({ label, checked, onChange }: { label: string, checked: boolean, onChange: (val: boolean) => void }) => (
|
||||
<label className="flex items-center p-4 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<div className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#492E61]/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-[#492E61]"></div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm font-medium text-gray-700">{label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
const SidebarItem = ({ id, label, icon: Icon, active, onClick }: any) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl transition-all duration-200 mb-1 ${
|
||||
active
|
||||
? "bg-[#492E61] text-white shadow-md"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-[#492E61]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={20} className={active ? "text-white" : "text-gray-400"} />
|
||||
<span className="font-medium">{label}</span>
|
||||
</div>
|
||||
{active && <ChevronRight size={16} className="text-white/80" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export const ProfilePage: React.FC = () => {
|
||||
const { user, token } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("personal");
|
||||
const [functions, setFunctions] = useState<any[]>([]);
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState<any>({
|
||||
id: "",
|
||||
nome: "",
|
||||
email: "",
|
||||
whatsapp: "",
|
||||
cpf_cnpj_titular: "",
|
||||
endereco: "",
|
||||
cidade: "",
|
||||
uf: "",
|
||||
banco: "",
|
||||
agencia: "",
|
||||
conta_pix: "",
|
||||
carro_disponivel: false,
|
||||
tem_estudio: false,
|
||||
qtd_estudio: 0,
|
||||
tipo_cartao: "",
|
||||
equipamentos: "",
|
||||
extra_por_equipamento: false,
|
||||
funcoes_ids: [],
|
||||
avatar_url: ""
|
||||
});
|
||||
|
||||
// Fetch Data
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) toast.error("Perfil profissional não encontrado.");
|
||||
else throw new Error("Falha ao carregar perfil");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setFormData({
|
||||
...data,
|
||||
carro_disponivel: data.carro_disponivel || false,
|
||||
tem_estudio: data.tem_estudio || false,
|
||||
extra_por_equipamento: data.extra_por_equipamento || false,
|
||||
qtd_estudio: data.qtd_estudio || 0,
|
||||
funcoes_ids: data.functions ? data.functions.map((f: any) => f.id) : []
|
||||
});
|
||||
|
||||
const funcsRes = await getFunctions();
|
||||
if (funcsRes.data) setFunctions(funcsRes.data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erro ao carregar dados");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [token]);
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleFunctionToggle = (funcId: string) => {
|
||||
setFormData((prev: any) => {
|
||||
const currentIds = prev.funcoes_ids || [];
|
||||
return currentIds.includes(funcId)
|
||||
? { ...prev, funcoes_ids: currentIds.filter((id: string) => id !== funcId) }
|
||||
: { ...prev, funcoes_ids: [...currentIds, funcId] };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (!token) throw new Error("Usuário não autenticado");
|
||||
|
||||
const res = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/${formData.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Erro ao salvar");
|
||||
toast.success("Perfil atualizado!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erro ao salvar alterações");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-[#492E61] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar onNavigate={(page) => window.location.href = `/${page}`} currentPage="profile" />
|
||||
|
||||
<div className="pt-28 sm:pt-32 lg:pt-36 pb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Meu Perfil</h1>
|
||||
<p className="text-gray-600">Gerencie suas informações pessoais e profissionais.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
|
||||
{/* Sidebar (Desktop) */}
|
||||
<div className="hidden lg:block lg:col-span-1">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-4 sticky top-32">
|
||||
<nav className="space-y-1">
|
||||
<SidebarItem
|
||||
id="personal"
|
||||
label="Dados Pessoais"
|
||||
icon={User}
|
||||
active={activeTab === 'personal'}
|
||||
onClick={() => setActiveTab('personal')}
|
||||
/>
|
||||
<SidebarItem
|
||||
id="address"
|
||||
label="Endereço & Contato"
|
||||
icon={MapPin}
|
||||
active={activeTab === 'address'}
|
||||
onClick={() => setActiveTab('address')}
|
||||
/>
|
||||
<SidebarItem
|
||||
id="bank"
|
||||
label="Dados Bancários"
|
||||
icon={CreditCard}
|
||||
active={activeTab === 'bank'}
|
||||
onClick={() => setActiveTab('bank')}
|
||||
/>
|
||||
<SidebarItem
|
||||
id="equipment"
|
||||
label="Profissional"
|
||||
icon={Camera}
|
||||
active={activeTab === 'equipment'}
|
||||
onClick={() => setActiveTab('equipment')}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Tabs */}
|
||||
<div className="lg:hidden mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-2 overflow-x-auto">
|
||||
<div className="flex gap-2 min-w-max">
|
||||
{['personal', 'address', 'bank', 'equipment'].map(id => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === id ? 'bg-[#492E61] text-white' : 'bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{id === 'personal' && 'Dados Pessoais'}
|
||||
{id === 'address' && 'Endereço'}
|
||||
{id === 'bank' && 'Banco'}
|
||||
{id === 'equipment' && 'Profissional'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-8 animate-in fade-in duration-300">
|
||||
|
||||
<div className="flex items-center justify-between pb-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
{activeTab === "personal" && "Informações Pessoais"}
|
||||
{activeTab === "address" && "Endereço e Contato"}
|
||||
{activeTab === "bank" && "Dados Bancários"}
|
||||
{activeTab === "equipment" && "Perfil Profissional"}
|
||||
</h2>
|
||||
<div className="hidden sm:block">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Salvando..." : "Salvar Alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- PERSONAL --- */}
|
||||
{activeTab === "personal" && (
|
||||
<div className="space-y-8">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative group">
|
||||
<div className="w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden border-4 border-white shadow-lg">
|
||||
{formData.avatar_url ? (
|
||||
<img src={formData.avatar_url} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User size={40} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<label className="absolute bottom-0 right-0 p-2 bg-[#492E61] rounded-full text-white cursor-pointer hover:bg-[#5a3a7a] transition-all shadow-md transform group-hover:scale-110">
|
||||
<Camera size={16} />
|
||||
<input type="file" className="hidden" accept="image/*" disabled />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-gray-900">Foto de Perfil</h3>
|
||||
<p className="text-sm text-gray-500">Recomendado: 400x400px. Max: 2MB.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputField
|
||||
label="Nome Completo"
|
||||
icon={User}
|
||||
value={formData.nome}
|
||||
onChange={(e) => handleChange("nome", e.target.value)}
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="CPF/CNPJ"
|
||||
icon={FileText}
|
||||
value={formData.cpf_cnpj_titular}
|
||||
onChange={(e) => handleChange("cpf_cnpj_titular", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- ADDRESS --- */}
|
||||
{activeTab === "address" && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputField
|
||||
label="Email"
|
||||
icon={Mail}
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="WhatsApp"
|
||||
icon={Phone}
|
||||
value={formData.whatsapp}
|
||||
onChange={(e) => handleChange("whatsapp", e.target.value)}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<InputField
|
||||
label="Endereço"
|
||||
icon={MapPin}
|
||||
value={formData.endereco}
|
||||
onChange={(e) => handleChange("endereco", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
label="Cidade"
|
||||
icon={MapPin}
|
||||
value={formData.cidade}
|
||||
onChange={(e) => handleChange("cidade", e.target.value)}
|
||||
/>
|
||||
<InputField
|
||||
label="UF"
|
||||
icon={MapPin}
|
||||
value={formData.uf}
|
||||
onChange={(e) => handleChange("uf", e.target.value)}
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- BANK --- */}
|
||||
{activeTab === "bank" && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<InputField
|
||||
label="Banco"
|
||||
icon={Briefcase}
|
||||
value={formData.banco}
|
||||
onChange={(e) => handleChange("banco", e.target.value)}
|
||||
/>
|
||||
<InputField
|
||||
label="Agência"
|
||||
icon={Briefcase}
|
||||
value={formData.agencia}
|
||||
onChange={(e) => handleChange("agencia", e.target.value)}
|
||||
/>
|
||||
<InputField
|
||||
label="Chave PIX"
|
||||
icon={DollarSign}
|
||||
value={formData.conta_pix}
|
||||
onChange={(e) => handleChange("conta_pix", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- EQUIPMENT --- */}
|
||||
{activeTab === "equipment" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Funções Atuantes</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{functions.map((func) => (
|
||||
<label key={func.id} className={`flex items-center p-3 rounded-xl border cursor-pointer transition-all ${
|
||||
formData.funcoes_ids?.includes(func.id)
|
||||
? "border-[#492E61] bg-[#492E61]/5 text-[#492E61] ring-1 ring-[#492E61]"
|
||||
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
}`}>
|
||||
<div className={`w-5 h-5 rounded border mr-3 flex items-center justify-center transition-colors ${
|
||||
formData.funcoes_ids?.includes(func.id) ? "bg-[#492E61] border-[#492E61]" : "border-gray-300 bg-white"
|
||||
}`}>
|
||||
{formData.funcoes_ids?.includes(func.id) && <Check size={14} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{func.nome}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={formData.funcoes_ids?.includes(func.id) || false}
|
||||
onChange={() => handleFunctionToggle(func.id)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4 border-t border-gray-100">
|
||||
<Toggle
|
||||
label="Possui carro disponível?"
|
||||
checked={formData.carro_disponivel}
|
||||
onChange={(checked) => handleChange("carro_disponivel", checked)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Cobra extra por equipamento?"
|
||||
checked={formData.extra_por_equipamento}
|
||||
onChange={(checked) => handleChange("extra_por_equipamento", checked)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Possui estúdio?"
|
||||
checked={formData.tem_estudio}
|
||||
onChange={(checked) => handleChange("tem_estudio", checked)}
|
||||
/>
|
||||
{formData.tem_estudio && (
|
||||
<InputField
|
||||
label="Qtd. Estúdios"
|
||||
icon={Briefcase}
|
||||
type="number"
|
||||
value={formData.qtd_estudio}
|
||||
onChange={(e) => handleChange("qtd_estudio", parseInt(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<InputField
|
||||
label="Tipo de Cartão de Memória"
|
||||
icon={Briefcase}
|
||||
value={formData.tipo_cartao}
|
||||
onChange={(e) => handleChange("tipo_cartao", e.target.value)}
|
||||
placeholder="Ex: SD, CF Express..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Lista de Equipamentos</label>
|
||||
<textarea
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#492E61] focus:border-transparent transition-all resize-y min-h-[120px]"
|
||||
value={formData.equipamentos || ""}
|
||||
onChange={(e) => handleChange("equipamentos", e.target.value)}
|
||||
placeholder="Liste suas câmeras, lentes e flashes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-6 sm:hidden">
|
||||
<Button type="submit" disabled={isSaving} className="w-full">
|
||||
{isSaving ? "Salvando..." : "Salvar Alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue