Backend: - Adiciona endpoint `PUT /api/me` para permitir atualização de dados do usuário logado. - Implementa query `UpdateCadastroCliente` e função de serviço [UpdateClientData]para persistir alterações de clientes. - Atualiza handlers [Me], [Login] e [ListPending] para incluir e mapear corretamente campos de cliente (CPF, Endereço, Telefone). - Corrige mapeamento do campo `phone` na struct de resposta do usuário. Frontend: - Habilita o formulário de edição em [Profile.tsx] para usuários do tipo 'CLIENTE' (Event Owner). - Adiciona função [updateUserProfile] em [apiService.ts] para consumir o novo endpoint. - Atualiza [AuthContext] para persistir campos do cliente (CPF, Endereço, etc.) durante a restauração de sessão ([restoreSession], corrigindo o bug de perfil vazio ao recarregar a página. - Padroniza envio de dados no Registro e Aprovação para usar `snake_case` (ex: `cpf_cnpj`, `professional_type`). - Atualiza tipos em [types.ts] para incluir campos de endereço e documentos.
776 lines
34 KiB
TypeScript
776 lines
34 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import {
|
|
User, Mail, Phone, MapPin, DollarSign, Briefcase,
|
|
Camera, FileText, Check, CreditCard, Save, ChevronRight, Loader2, X
|
|
} from "lucide-react";
|
|
import { Navbar } from "../components/Navbar";
|
|
import { Button } from "../components/Button";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { getFunctions, createProfessional, updateProfessional, updateUserProfile } from "../services/apiService";
|
|
import { toast } from "react-hot-toast";
|
|
import { formatCPFCNPJ, formatPhone } from "../utils/masks";
|
|
|
|
// --- 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;
|
|
readOnly?: boolean;
|
|
onBlur?: (e: any) => void;
|
|
}
|
|
|
|
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 disabled:bg-gray-100 disabled:text-gray-500 read-only:bg-gray-50 read-only:text-gray-600`}
|
|
{...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 [isUploading, setIsUploading] = useState(false);
|
|
const [activeTab, setActiveTab] = useState("personal");
|
|
const [functions, setFunctions] = useState<any[]>([]);
|
|
const [isNewProfile, setIsNewProfile] = useState(false);
|
|
const [showInitModal, setShowInitModal] = useState(false);
|
|
|
|
// Form State
|
|
const [formData, setFormData] = useState<any>({
|
|
id: "",
|
|
nome: "",
|
|
email: "",
|
|
whatsapp: "",
|
|
cpf_cnpj_titular: "",
|
|
cep: "",
|
|
rua: "",
|
|
numero: "",
|
|
bairro: "",
|
|
endereco: "",
|
|
cidade: "",
|
|
uf: "",
|
|
banco: "",
|
|
agencia: "",
|
|
conta: "",
|
|
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 funcsRes = await getFunctions();
|
|
if (funcsRes.data) setFunctions(funcsRes.data);
|
|
|
|
if (user?.role === "EVENT_OWNER") {
|
|
// Clients don't have professional profile. Populate from User.
|
|
// Ensure user object has these fields (mapped in AuthContext)
|
|
setFormData({
|
|
...formData,
|
|
nome: user.name || "",
|
|
email: user.email || "",
|
|
whatsapp: user.phone || "",
|
|
cpf_cnpj_titular: user.cpf_cnpj || "",
|
|
cep: user.cep || "",
|
|
endereco: user.endereco || "",
|
|
numero: user.numero || "",
|
|
complemento: user.complemento || "",
|
|
bairro: user.bairro || "",
|
|
cidade: user.cidade || "",
|
|
uf: user.estado || "",
|
|
});
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Try to fetch existing profile
|
|
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) {
|
|
// Profile not found -> Initialize New Profile
|
|
setIsNewProfile(true);
|
|
setShowInitModal(true);
|
|
|
|
// Pre-fill from User Authentication
|
|
setFormData((prev: any) => ({
|
|
...prev,
|
|
nome: user?.name || "",
|
|
email: user?.email || "",
|
|
}));
|
|
|
|
return;
|
|
}
|
|
throw new Error("Falha ao carregar perfil");
|
|
}
|
|
|
|
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,
|
|
conta: data.conta || "",
|
|
// Populate email if missing from backend, fallback to user email
|
|
email: data.email || user?.email || "",
|
|
funcoes_ids: data.functions ? data.functions.map((f: any) => f.id) : []
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Erro ao carregar dados do perfil.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [token, user]);
|
|
|
|
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] };
|
|
});
|
|
};
|
|
|
|
// Avatar Upload
|
|
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
toast.error("A imagem deve ter no máximo 2MB.");
|
|
return;
|
|
}
|
|
|
|
setIsUploading(true);
|
|
try {
|
|
const filename = `avatar_${user?.id}_${Date.now()}_${file.name}`;
|
|
const resUrl = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/auth/upload-url`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ filename, content_type: file.type })
|
|
});
|
|
|
|
if (!resUrl.ok) throw new Error("Falha ao obter URL de upload");
|
|
const { upload_url, public_url } = await resUrl.json();
|
|
|
|
const uploadRes = await fetch(upload_url, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': file.type },
|
|
body: file
|
|
});
|
|
|
|
if (!uploadRes.ok) throw new Error("Falha ao enviar imagem");
|
|
|
|
setFormData((prev: any) => ({ ...prev, avatar_url: public_url }));
|
|
toast.success("Imagem enviada! Salve o perfil para confirmar.");
|
|
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Erro ao enviar imagem.");
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleCepBlur = async (e: any) => {
|
|
const cep = e.target.value?.replace(/\D/g, '');
|
|
if (cep?.length !== 8) return;
|
|
|
|
const toastId = toast.loading("Buscando endereço...");
|
|
try {
|
|
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
|
|
const data = await res.json();
|
|
|
|
if (data.erro) {
|
|
toast.error("CEP não encontrado.", { id: toastId });
|
|
return;
|
|
}
|
|
|
|
const formattedAddress = `${data.logradouro}, ${data.bairro}`;
|
|
|
|
setFormData((prev: any) => ({
|
|
...prev,
|
|
endereco: formattedAddress,
|
|
cidade: data.localidade,
|
|
uf: data.uf,
|
|
rua: data.logradouro,
|
|
bairro: data.bairro
|
|
}));
|
|
toast.success("Endereço encontrado!", { id: toastId });
|
|
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Erro ao buscar CEP.", { id: toastId });
|
|
}
|
|
};
|
|
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setIsSaving(true);
|
|
try {
|
|
if (!token) throw new Error("Usuário não autenticado");
|
|
|
|
if (user?.role === "EVENT_OWNER") {
|
|
const clientPayload = {
|
|
name: formData.nome,
|
|
phone: formData.whatsapp,
|
|
cpf_cnpj: formData.cpf_cnpj_titular,
|
|
cep: formData.cep,
|
|
endereco: formData.endereco,
|
|
numero: formData.numero,
|
|
complemento: formData.complemento,
|
|
bairro: formData.bairro,
|
|
cidade: formData.cidade,
|
|
estado: formData.uf
|
|
};
|
|
const res = await updateUserProfile(clientPayload, token);
|
|
if (res.error) throw new Error(res.error);
|
|
toast.success("Perfil atualizado com sucesso!");
|
|
setIsSaving(false);
|
|
return;
|
|
}
|
|
|
|
// Payload preparation
|
|
// For create/update, we need `funcao_profissional_id` (single) for backward compatibility optionally
|
|
// But we primarily use `funcoes_ids`.
|
|
// If `funcoes_ids` is empty, user needs to select at least one?
|
|
// For now, let's just pick the first one as "primary" if backend requires it.
|
|
// Backend create DTO has `funcao_profissional_id`.
|
|
|
|
const payload = {
|
|
...formData,
|
|
// Backend compatibility: if funcao_profissional_id is empty/string, try to set from array
|
|
funcao_profissional_id: formData.funcoes_ids && formData.funcoes_ids.length > 0
|
|
? formData.funcoes_ids[0]
|
|
: formData.funcao_profissional_id
|
|
};
|
|
|
|
if (!payload.funcao_profissional_id && isNewProfile && formData.funcoes_ids.length === 0) {
|
|
// If no functions selected for new profile, it might fail if backend requires it.
|
|
// Let's allow it for now, user might add later.
|
|
// Or toast warning?
|
|
}
|
|
|
|
let res;
|
|
if (isNewProfile) {
|
|
// CREATE
|
|
res = await createProfessional(payload, token);
|
|
} else {
|
|
// UPDATE
|
|
res = await updateProfessional(formData.id, payload, token);
|
|
}
|
|
|
|
if (res.error) throw new Error(res.error);
|
|
|
|
toast.success(isNewProfile ? "Perfil criado com sucesso!" : "Perfil atualizado com sucesso!");
|
|
|
|
// If created, switch to edit mode
|
|
if (isNewProfile && res.data) {
|
|
setIsNewProfile(false);
|
|
setFormData((prev: any) => ({ ...prev, id: res.data.id }));
|
|
}
|
|
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
toast.error(error.message || "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 de cadastro.</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')}
|
|
/>
|
|
|
|
{/* Hide for clients/event owners */}
|
|
{user?.role !== "EVENT_OWNER" && (
|
|
<>
|
|
<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',
|
|
...(user?.role !== "EVENT_OWNER" ? ['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" />
|
|
)}
|
|
{isUploading && (
|
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
|
</div>
|
|
)}
|
|
</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/*"
|
|
onChange={handleAvatarChange}
|
|
disabled={isUploading}
|
|
/>
|
|
</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", formatCPFCNPJ(e.target.value))}
|
|
maxLength={18}
|
|
/>
|
|
</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", formatPhone(e.target.value))}
|
|
maxLength={15}
|
|
/>
|
|
|
|
<div className="md:col-span-2 border-t pt-4 mt-2">
|
|
<h3 className="font-medium text-gray-900 mb-4">Endereço (Busca por CEP)</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<InputField
|
|
label="CEP"
|
|
icon={MapPin}
|
|
placeholder="00000-000"
|
|
onBlur={handleCepBlur}
|
|
maxLength={9}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<InputField
|
|
label="Endereço (Rua e Bairro)"
|
|
icon={MapPin}
|
|
value={formData.endereco || ""}
|
|
onChange={(e) => handleChange("endereco", e.target.value)}
|
|
readOnly
|
|
/>
|
|
</div>
|
|
<InputField
|
|
label="Número"
|
|
icon={MapPin}
|
|
placeholder="Ex: 123"
|
|
value={formData.numero || ""} // We don't have numero column in DB separate? DB has only Endereco properly.
|
|
// Note: Backend schema treats 'endereco' as single string. We should probably append number to endereco on save or just let user edit endereco if they want, but use 'lock' as requested.
|
|
// User asked to 'lock address' but 'allow number edit'.
|
|
// Since we store single string 'endereco', we might need to be smart.
|
|
// For now: We display 'endereco' (street + hood) as readOnly.
|
|
// We display 'numero' input (not persisted separately, or must be concatenated before save?)
|
|
// The backend `endereco` field usually holds everything.
|
|
// Let's assume we append Number to Endereco on Submit?
|
|
// Or user asked "Travar endereço liberar numero".
|
|
// Simpler: Allow user to edit number separately, and we append it?
|
|
// But `endereco` state is readOnly.
|
|
// Let's leave `numero` purely visible here, but since schema doesn't have `numero`, we might lose it if we don't concat.
|
|
// Better approach: Let `endereco` be full string, but maybe just `readOnly={false}`?
|
|
// "Travar endereço": Make street readOnly.
|
|
// So we need distinct fields or concat.
|
|
// Let's concat on save: `address` (Rua X) + check if `numero` is in it?
|
|
// Actually, let's keep it simple: Address field is READONLY. Number field is EDITABLE.
|
|
// On submit, we should ideally combine them if Endereco doesn't contain number.
|
|
// But I'll just save `endereco` as is (Rua X, Bairro) and ignore number for now? No, that's bad.
|
|
// Let's make `endereco` editable for now to be safe, but populated by CEP, OR append Number to it?
|
|
// I will concat `${formData.rua}, ${formData.numero} - ${formData.bairro}` if available before save?
|
|
// Yes, let's update `handleSubmit` effectively? Or `useEffect`.
|
|
// Let's modify `handleSubmit` logic or just let user edit `endereco` if they really need to.
|
|
// But request said "Travar".
|
|
// I'll leave as is with `readOnly` on Endereco, and `Numero` separately.
|
|
// Wait, if I don't save `Numero` anywhere, it's lost.
|
|
// I WILL MODIFY `endereco` in state when `numero` changes!
|
|
onChange={(e) => {
|
|
const num = e.target.value;
|
|
setFormData((prev: any) => ({
|
|
...prev,
|
|
numero: num,
|
|
// Update main address string: Rua X, <Num> - Bairro
|
|
// Only if we have parts
|
|
endereco: prev.rua ? `${prev.rua}, ${num} - ${prev.bairro || ''}` : prev.endereco
|
|
}));
|
|
}}
|
|
/>
|
|
<InputField
|
|
label="Cidade"
|
|
icon={MapPin}
|
|
value={formData.cidade || ""}
|
|
readOnly
|
|
/>
|
|
<InputField
|
|
label="UF"
|
|
icon={MapPin}
|
|
value={formData.uf || ""}
|
|
readOnly
|
|
maxLength={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- BANK --- */}
|
|
{activeTab === "bank" && (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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="Conta"
|
|
icon={Briefcase}
|
|
value={formData.conta || ""}
|
|
onChange={(e) => handleChange("conta", e.target.value)}
|
|
placeholder="Número da Conta"
|
|
/>
|
|
<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)}
|
|
/>
|
|
{/* REMOVED: Cobra extra por equipamento */}
|
|
<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..." : "Save Changes"}
|
|
</Button>
|
|
</div>
|
|
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Init Profile Modal */}
|
|
{showInitModal && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6 animate-in fade-in zoom-in duration-200">
|
|
<div className="text-center mb-6">
|
|
<div className="w-16 h-16 bg-[#492E61]/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<User size={32} className="text-[#492E61]" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-gray-900 mb-2">Complete seu Cadastro</h3>
|
|
<p className="text-gray-600">
|
|
Seus dados ainda não estão completos.
|
|
Preencha as informações abaixo para começar.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
type="button"
|
|
onClick={() => {
|
|
setShowInitModal(false);
|
|
toast.success("Preencha os dados e clique em Salvar");
|
|
}}
|
|
>
|
|
Começar Cadastro
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|