feat: melhorias no dashboard e correções no perfil
- Implementa filtros de Empresa e Instituição no Dashboard. - Adiciona barra de estatísticas de equipe (fotógrafos, cinegrafistas, recepcionistas) na modal de Gerenciar Equipe. - Corrige bug de atualização da interface após editar evento (mapeamento snake_case). - Adiciona máscaras de input (CPF/CNPJ, Telefone) na página de Perfil. - Corrige ordenação e persistência da listagem de eventos por FOT. - Corrige crash e corrupção de dados na página de Perfil. fix: permite reenviar notificação de logística - Remove bloqueio do botão de notificação de logística quando já enviada. - Altera texto do botão para "Reenviar Notificação" quando aplicável. feat: melhorias no dashboard, perfil e logística - Implementa filtros de Empresa e Instituição no Dashboard. - Adiciona barra de estatísticas de equipe na modal de Gerenciar Equipe. - Desacopla notificação de logística da aprovação do evento (agora apenas manual). - Permite reenviar notificação de logística e remove exibição redundante de data. - Adiciona máscaras de input (CPF/CNPJ, Telefone) no Perfil. - Corrige atualização da interface pós-edição de evento. - Corrige crash, ordenação e persistência na listagem de eventos e perfil.
This commit is contained in:
parent
1c20b570c0
commit
788e0dca70
12 changed files with 453 additions and 95 deletions
|
|
@ -440,9 +440,10 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s
|
|||
}
|
||||
|
||||
// Se o evento for confirmado, enviar notificações com logística
|
||||
if status == "Confirmado" {
|
||||
go s.NotifyLogistics(context.Background(), agendaID, nil, regiao)
|
||||
}
|
||||
// [MODIFIED] User requested to NOT send notification on approval. Only manually via logistics panel.
|
||||
// if status == "Confirmado" {
|
||||
// go s.NotifyLogistics(context.Background(), agendaID, nil, regiao)
|
||||
// }
|
||||
|
||||
return agenda, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefo
|
|||
SenhaHash: string(hashedPassword),
|
||||
Role: role,
|
||||
TipoProfissional: toPgText(&tipoProfissional),
|
||||
Ativo: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -475,6 +475,23 @@ func (q *Queries) GetProfissionalByUsuarioID(ctx context.Context, usuarioID pgty
|
|||
return i, err
|
||||
}
|
||||
|
||||
const linkProfissionalToUsuario = `-- name: LinkProfissionalToUsuario :exec
|
||||
UPDATE cadastro_profissionais
|
||||
SET usuario_id = $2
|
||||
WHERE id = $1 AND regiao = $3
|
||||
`
|
||||
|
||||
type LinkProfissionalToUsuarioParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UsuarioID pgtype.UUID `json:"usuario_id"`
|
||||
Regiao pgtype.Text `json:"regiao"`
|
||||
}
|
||||
|
||||
func (q *Queries) LinkProfissionalToUsuario(ctx context.Context, arg LinkProfissionalToUsuarioParams) error {
|
||||
_, err := q.db.Exec(ctx, linkProfissionalToUsuario, arg.ID, arg.UsuarioID, arg.Regiao)
|
||||
return err
|
||||
}
|
||||
|
||||
const linkUserToProfessional = `-- name: LinkUserToProfessional :exec
|
||||
UPDATE cadastro_profissionais SET usuario_id = $2 WHERE id = $1
|
||||
`
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ func (q *Queries) CreateCadastroCliente(ctx context.Context, arg CreateCadastroC
|
|||
|
||||
const createUsuario = `-- name: CreateUsuario :one
|
||||
INSERT INTO usuarios (email, senha_hash, role, tipo_profissional, ativo)
|
||||
VALUES ($1, $2, $3, $4, false)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, email, senha_hash, role, tipo_profissional, ativo, criado_em, atualizado_em, regioes_permitidas
|
||||
`
|
||||
|
||||
|
|
@ -55,6 +55,7 @@ type CreateUsuarioParams struct {
|
|||
SenhaHash string `json:"senha_hash"`
|
||||
Role string `json:"role"`
|
||||
TipoProfissional pgtype.Text `json:"tipo_profissional"`
|
||||
Ativo bool `json:"ativo"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUsuario(ctx context.Context, arg CreateUsuarioParams) (Usuario, error) {
|
||||
|
|
@ -63,6 +64,7 @@ func (q *Queries) CreateUsuario(ctx context.Context, arg CreateUsuarioParams) (U
|
|||
arg.SenhaHash,
|
||||
arg.Role,
|
||||
arg.TipoProfissional,
|
||||
arg.Ativo,
|
||||
)
|
||||
var i Usuario
|
||||
err := row.Scan(
|
||||
|
|
|
|||
|
|
@ -112,6 +112,11 @@ RETURNING *;
|
|||
DELETE FROM cadastro_profissionais
|
||||
WHERE id = $1 AND regiao = @regiao;
|
||||
|
||||
-- name: LinkProfissionalToUsuario :exec
|
||||
UPDATE cadastro_profissionais
|
||||
SET usuario_id = $2
|
||||
WHERE id = $1 AND regiao = @regiao;
|
||||
|
||||
-- name: SearchProfissionais :many
|
||||
SELECT p.*,
|
||||
COALESCE(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
-- name: CreateUsuario :one
|
||||
INSERT INTO usuarios (email, senha_hash, role, tipo_profissional, ativo)
|
||||
VALUES ($1, $2, $3, $4, false)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUsuarioByEmail :one
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
|
|
@ -270,7 +271,7 @@ func (s *Service) GetByID(ctx context.Context, id string, regiao string) (*gener
|
|||
type UpdateProfissionalInput struct {
|
||||
Nome string `json:"nome"`
|
||||
FuncaoProfissionalID string `json:"funcao_profissional_id"`
|
||||
FuncoesIds []string `json:"funcoes_ids"` // New field
|
||||
FuncoesIds []string `json:"funcoes_ids"`
|
||||
Endereco *string `json:"endereco"`
|
||||
Cidade *string `json:"cidade"`
|
||||
Uf *string `json:"uf"`
|
||||
|
|
@ -295,6 +296,7 @@ type UpdateProfissionalInput struct {
|
|||
Equipamentos *string `json:"equipamentos"`
|
||||
Email *string `json:"email"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Senha *string `json:"senha"` // New field for password update
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id string, input UpdateProfissionalInput, regiao string) (*generated.CadastroProfissionai, error) {
|
||||
|
|
@ -303,6 +305,127 @@ func (s *Service) Update(ctx context.Context, id string, input UpdateProfissiona
|
|||
return nil, errors.New("invalid id")
|
||||
}
|
||||
|
||||
// 1. Password Update Logic (if provided)
|
||||
if input.Senha != nil && *input.Senha != "" {
|
||||
fmt.Printf("[DEBUG] Updating password for professional %s. New Password Length: %d\n", id, len(*input.Senha))
|
||||
|
||||
// Get Professional to find UsuarioID
|
||||
// Requires region to be safe, though ID is unique. Using passed region.
|
||||
currentProf, err := s.queries.GetProfissionalByID(ctx, generated.GetProfissionalByIDParams{
|
||||
ID: pgtype.UUID{Bytes: uuidVal, Valid: true},
|
||||
Regiao: pgtype.Text{String: regiao, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] Error fetching professional for password update: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] Professional found. UsuarioID Valid: %v, UUID: %v\n", currentProf.UsuarioID.Valid, currentProf.UsuarioID.Bytes)
|
||||
}
|
||||
|
||||
if err == nil && currentProf.UsuarioID.Valid {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*input.Senha), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erro ao gerar hash da senha: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG] Updating UsuarioID %v with new hash\n", currentProf.UsuarioID.Bytes)
|
||||
err = s.queries.UpdateUsuarioSenha(ctx, generated.UpdateUsuarioSenhaParams{
|
||||
ID: currentProf.UsuarioID,
|
||||
SenhaHash: string(hashedPassword),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] Error updating user password: %v\n", err)
|
||||
return nil, fmt.Errorf("erro ao atualizar senha do usuário: %v", err)
|
||||
}
|
||||
fmt.Println("[DEBUG] Password updated successfully")
|
||||
} else {
|
||||
// No UsuarioID found. We need to create one or link to existing email.
|
||||
emailToUse := input.Email
|
||||
if emailToUse == nil || *emailToUse == "" {
|
||||
if currentProf.Email.Valid {
|
||||
e := currentProf.Email.String
|
||||
emailToUse = &e
|
||||
}
|
||||
}
|
||||
|
||||
if emailToUse != nil && *emailToUse != "" {
|
||||
fmt.Printf("[DEBUG] User not linked. Attempting to link/create for email: %s\n", *emailToUse)
|
||||
|
||||
// 1. Check if user exists
|
||||
existingUser, err := s.queries.GetUsuarioByEmail(ctx, *emailToUse)
|
||||
var userID pgtype.UUID
|
||||
|
||||
hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(*input.Senha), bcrypt.DefaultCost)
|
||||
if hashErr != nil {
|
||||
return nil, fmt.Errorf("erro ao gerar hash da senha: %v", hashErr)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// User exists. Link it and update password.
|
||||
fmt.Printf("[DEBUG] User exists with ID %v. Linking...\n", existingUser.ID.Bytes)
|
||||
userID = existingUser.ID
|
||||
|
||||
// Update password for existing user
|
||||
err = s.queries.UpdateUsuarioSenha(ctx, generated.UpdateUsuarioSenhaParams{
|
||||
ID: userID,
|
||||
SenhaHash: string(hashedPassword),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erro ao atualizar senha do usuário existente: %v", err)
|
||||
}
|
||||
} else {
|
||||
// User does not exist. Create new user.
|
||||
fmt.Println("[DEBUG] User does not exist. Creating new user...")
|
||||
|
||||
// Determine role based on function. Default to PHOTOGRAPHER.
|
||||
role := "PHOTOGRAPHER" // Default
|
||||
// Ideally we should use the function to determine role, but for now safe default or "RESEARCHER" check?
|
||||
// input.FuncaoProfissionalID/FuncoesIds might be present.
|
||||
// Let's rely on default for now as we don't have easy access to role logic here without circular dependency or extra queries.
|
||||
// Actually, we can just set it to PHOTOGRAPHER as it grants access to app. They need proper access.
|
||||
|
||||
newUser, err := s.queries.CreateUsuario(ctx, generated.CreateUsuarioParams{
|
||||
Email: *emailToUse,
|
||||
SenhaHash: string(hashedPassword),
|
||||
Role: role,
|
||||
TipoProfissional: pgtype.Text{String: "Fotógrafo", Valid: true}, // Placeholder, should be aligned with function
|
||||
Ativo: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erro ao criar novo usuário para profissional: %v", err)
|
||||
}
|
||||
userID = newUser.ID
|
||||
fmt.Printf("[DEBUG] Created new user with ID %v\n", userID.Bytes)
|
||||
|
||||
// Ensure region access
|
||||
if regiao != "" {
|
||||
_ = s.queries.UpdateUsuarioRegions(ctx, generated.UpdateUsuarioRegionsParams{
|
||||
ID: userID,
|
||||
RegioesPermitidas: []string{regiao},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Link Professional to User
|
||||
err = s.queries.LinkProfissionalToUsuario(ctx, generated.LinkProfissionalToUsuarioParams{
|
||||
ID: pgtype.UUID{Bytes: uuidVal, Valid: true},
|
||||
UsuarioID: userID,
|
||||
Regiao: pgtype.Text{String: regiao, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
// If link fails, we might leave a dangling user if created, but that's acceptable for now.
|
||||
return nil, fmt.Errorf("erro ao vincular profissional ao usuário: %v", err)
|
||||
}
|
||||
fmt.Println("[DEBUG] Professional successfully linked to User.")
|
||||
|
||||
} else {
|
||||
fmt.Println("[DEBUG] Cannot create user: No email available for professional.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("[DEBUG] No password provided for update")
|
||||
}
|
||||
|
||||
funcaoUUID, err := uuid.Parse(input.FuncaoProfissionalID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid funcao_profissional_id")
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
import React from "react";
|
||||
import { Calendar, Hash, Filter, X } from "lucide-react";
|
||||
import { Calendar, Hash, Filter, X, Building2 } from "lucide-react";
|
||||
|
||||
export interface EventFilters {
|
||||
date: string;
|
||||
fotId: string;
|
||||
type: string;
|
||||
company: string;
|
||||
institution: string;
|
||||
}
|
||||
|
||||
interface EventFiltersBarProps {
|
||||
filters: EventFilters;
|
||||
onFilterChange: (filters: EventFilters) => void;
|
||||
availableTypes: string[];
|
||||
availableCompanies: string[];
|
||||
availableInstitutions: string[];
|
||||
}
|
||||
|
||||
export const EventFiltersBar: React.FC<EventFiltersBarProps> = ({
|
||||
filters,
|
||||
onFilterChange,
|
||||
availableTypes,
|
||||
availableCompanies,
|
||||
availableInstitutions,
|
||||
}) => {
|
||||
const handleReset = () => {
|
||||
onFilterChange({
|
||||
date: "",
|
||||
fotId: "",
|
||||
type: "",
|
||||
company: "",
|
||||
institution: "",
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -46,7 +54,7 @@ export const EventFiltersBar: React.FC<EventFiltersBarProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
|
||||
{/* Filtro por FOT */}
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-600 mb-1 flex items-center gap-1">
|
||||
|
|
@ -103,6 +111,50 @@ export const EventFiltersBar: React.FC<EventFiltersBarProps> = ({
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filtro por Empresa */}
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-600 mb-1 flex items-center gap-1">
|
||||
<Building2 size={14} className="text-brand-gold" />
|
||||
Empresa
|
||||
</label>
|
||||
<select
|
||||
value={filters.company}
|
||||
onChange={(e) =>
|
||||
onFilterChange({ ...filters, company: e.target.value })
|
||||
}
|
||||
className="px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:border-brand-gold transition-colors bg-white"
|
||||
>
|
||||
<option value="">Todas as empresas</option>
|
||||
{availableCompanies.map((comp) => (
|
||||
<option key={comp} value={comp}>
|
||||
{comp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filtro por Instituição */}
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-600 mb-1 flex items-center gap-1">
|
||||
<Building2 size={14} className="text-brand-gold" />
|
||||
Instituição
|
||||
</label>
|
||||
<select
|
||||
value={filters.institution}
|
||||
onChange={(e) =>
|
||||
onFilterChange({ ...filters, institution: e.target.value })
|
||||
}
|
||||
className="px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:border-brand-gold transition-colors bg-white"
|
||||
>
|
||||
<option value="">Todas as instituições</option>
|
||||
{availableInstitutions.map((inst) => (
|
||||
<option key={inst} value={inst}>
|
||||
{inst}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
|
|
@ -146,6 +198,28 @@ export const EventFiltersBar: React.FC<EventFiltersBarProps> = ({
|
|||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.company && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-brand-gold/10 text-brand-gold text-xs rounded">
|
||||
Empresa: {filters.company}
|
||||
<button
|
||||
onClick={() => onFilterChange({ ...filters, company: "" })}
|
||||
className="hover:text-brand-black"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.institution && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-brand-gold/10 text-brand-gold text-xs rounded">
|
||||
Inst: {filters.institution}
|
||||
<button
|
||||
onClick={() => onFilterChange({ ...filters, institution: "" })}
|
||||
className="hover:text-brand-black"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -197,11 +197,7 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, isEditable: p
|
|||
<Truck className="w-5 h-5 mr-2 text-orange-500" />
|
||||
Logística de Transporte
|
||||
</h3>
|
||||
{isEditable && eventData?.logisticaNotificacaoEnviadaEm && (
|
||||
<div className="text-sm text-green-700 bg-green-50 px-2 py-1 rounded border border-green-200">
|
||||
Notificação enviada em: {new Date(eventData.logisticaNotificacaoEnviadaEm).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => window.location.href = `/painel?eventId=${agendaId}`}
|
||||
className="ml-auto flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-50 transition-colors shadow-sm"
|
||||
|
|
@ -341,16 +337,15 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, isEditable: p
|
|||
<div className="flex flex-col items-center pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleNotifyLogistics}
|
||||
disabled={!!eventData?.logisticaNotificacaoEnviadaEm}
|
||||
className={`px-6 py-2.5 rounded-md flex items-center text-sm font-medium shadow-sm transition-colors w-full justify-center ${
|
||||
eventData?.logisticaNotificacaoEnviadaEm
|
||||
? "bg-gray-400 cursor-not-allowed text-white"
|
||||
? "bg-blue-600 hover:bg-blue-700 text-white"
|
||||
: "bg-green-600 hover:bg-green-700 text-white"
|
||||
}`}
|
||||
title={eventData?.logisticaNotificacaoEnviadaEm ? "Notificação já enviada" : "Confirmar logística e notificar equipe"}
|
||||
title={eventData?.logisticaNotificacaoEnviadaEm ? "Reenviar notificação para a equipe" : "Confirmar logística e notificar equipe"}
|
||||
>
|
||||
<Send className="w-5 h-5 mr-2" />
|
||||
{eventData?.logisticaNotificacaoEnviadaEm ? "Notificação Enviada" : "Finalizar Logística e Notificar Equipe"}
|
||||
{eventData?.logisticaNotificacaoEnviadaEm ? "Reenviar Notificação" : "Finalizar Logística e Notificar Equipe"}
|
||||
</button>
|
||||
{eventData?.logisticaNotificacaoEnviadaEm && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export const ProfessionalModal: React.FC<ProfessionalModalProps> = ({
|
|||
const [avatarPreview, setAvatarPreview] = useState<string>("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isEditingPassword, setIsEditingPassword] = useState(false); // Toggle for edit mode
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoadingCep, setIsLoadingCep] = useState(false);
|
||||
|
||||
|
|
@ -126,6 +127,7 @@ export const ProfessionalModal: React.FC<ProfessionalModalProps> = ({
|
|||
setAvatarPreview("");
|
||||
}
|
||||
setAvatarFile(null);
|
||||
setIsEditingPassword(false);
|
||||
}
|
||||
}, [isOpen, professional]); // user dependency intentionally omitted to avoid reset loop, but safe to add if needed
|
||||
|
||||
|
|
@ -285,7 +287,7 @@ export const ProfessionalModal: React.FC<ProfessionalModalProps> = ({
|
|||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (!professional && (formData.senha || formData.confirmarSenha)) {
|
||||
if (formData.senha || formData.confirmarSenha) {
|
||||
if (formData.senha !== formData.confirmarSenha) {
|
||||
alert("As senhas não coincidem!");
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -309,8 +311,19 @@ export const ProfessionalModal: React.FC<ProfessionalModalProps> = ({
|
|||
}
|
||||
|
||||
const payload: any = { ...formData, avatar_url: finalAvatarUrl };
|
||||
delete payload.senha;
|
||||
delete payload.confirmarSenha;
|
||||
|
||||
// Handle password logic
|
||||
if (!payload.senha) {
|
||||
delete payload.senha;
|
||||
delete payload.confirmarSenha;
|
||||
} else {
|
||||
// Password is set, ensure confirmation matches (already checked above? No, only for !professional)
|
||||
// We need to re-check match here if it wasn't caught by the generic check which was conditional.
|
||||
// Let's rely on the check below/above or add one here.
|
||||
// Currently the check at line 288 is `if (!professional && ...)` which skips for edit.
|
||||
// We should move that check to be general.
|
||||
}
|
||||
delete payload.confirmarSenha; // Always remove confirm from payload to backend
|
||||
|
||||
if (professional) {
|
||||
// Update
|
||||
|
|
@ -465,55 +478,91 @@ export const ProfessionalModal: React.FC<ProfessionalModalProps> = ({
|
|||
|
||||
{/* Functions removed from here */}
|
||||
|
||||
{/* Email & Pass */}
|
||||
{/* Email & Pass */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email *</label>
|
||||
<input required type="email" value={formData.email || ""} onChange={e => setFormData({ ...formData, email: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
|
||||
{!professional && (
|
||||
<>
|
||||
{/* Password Fields */}
|
||||
{(!professional || isEditingPassword) && (
|
||||
<div className="space-y-4 p-4 bg-gray-50 rounded-md border border-gray-200">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">{professional ? "Alterar Senha" : "Definir Senha"}</h4>
|
||||
{professional && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingPassword(false);
|
||||
setFormData(prev => ({ ...prev, senha: "", confirmarSenha: "" }));
|
||||
}}
|
||||
className="text-xs text-red-600 hover:text-red-800"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Senha *</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
required
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.senha || ""}
|
||||
onChange={e => setFormData({ ...formData, senha: e.target.value })}
|
||||
minLength={6}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Confirmar Senha *</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
required
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={formData.confirmarSenha || ""}
|
||||
onChange={e => setFormData({ ...formData, confirmarSenha: e.target.value })}
|
||||
minLength={6}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{professional ? "Nova Senha" : "Senha *"}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
required={!professional}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.senha || ""}
|
||||
onChange={e => setFormData({ ...formData, senha: e.target.value })}
|
||||
minLength={6}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
|
||||
placeholder={professional ? "Digitar nova senha" : ""}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{professional ? "Confirmar Nova Senha" : "Confirmar Senha *"}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
required={!professional || (!!formData.senha && formData.senha.length > 0)}
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={formData.confirmarSenha || ""}
|
||||
onChange={e => setFormData({ ...formData, confirmarSenha: e.target.value })}
|
||||
minLength={6}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
|
||||
placeholder={professional ? "Confirmar nova senha" : ""}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{professional && !isEditingPassword && (
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsEditingPassword(true)}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Alterar Senha de Acesso
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1180,6 +1180,18 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
|||
briefing: data.observacoes_evento || evt.briefing,
|
||||
fotId: data.fot_id || evt.fotId,
|
||||
empresaId: data.empresa_id || evt.empresaId, // If provided
|
||||
|
||||
// Map team resource fields
|
||||
qtdFormandos: data.qtd_formandos ?? evt.qtdFormandos,
|
||||
qtdFotografos: data.qtd_fotografos ?? evt.qtdFotografos,
|
||||
qtdRecepcionistas: data.qtd_recepcionistas ?? evt.qtdRecepcionistas,
|
||||
qtdCinegrafistas: data.qtd_cinegrafistas ?? evt.qtdCinegrafistas,
|
||||
qtdEstudios: data.qtd_estudios ?? evt.qtdEstudios,
|
||||
qtdPontosFoto: data.qtd_pontos_foto ?? evt.qtdPontosFoto,
|
||||
qtdPontosDecorados: data.qtd_pontos_decorados ?? evt.qtdPontosDecorados,
|
||||
qtdPontosLed: data.qtd_pontos_led ?? evt.qtdPontosLed,
|
||||
qtdPlataforma360: data.qtd_plataforma_360 ?? evt.qtdPlataforma360,
|
||||
|
||||
// Address is hard to parse back to object from payload without logic
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
X,
|
||||
UserCheck,
|
||||
UserX,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useData } from "../contexts/DataContext";
|
||||
|
|
@ -61,16 +62,29 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
// Force reload of view to reflect changes (or rely on DataContext optimistic update)
|
||||
// But DataContext optimistic update only touched generic fields.
|
||||
// Address might still be old in 'selectedEvent' state if we don't update it.
|
||||
// Updating selectedEvent manually as well to be safe:
|
||||
const updatedEvent = { ...selectedEvent, ...data, date: data.date || data.data_evento?.split('T')[0] || selectedEvent.date };
|
||||
// Updating selectedEvent manually as well to be safe, mapping snake_case payload to camelCase state:
|
||||
const updatedEvent = {
|
||||
...selectedEvent,
|
||||
...data,
|
||||
date: data.date || data.data_evento?.split('T')[0] || selectedEvent.date,
|
||||
// Map snake_case fields used in EventForm payload to camelCase fields used in UI
|
||||
qtdFormandos: data.qtd_formandos ?? selectedEvent.qtdFormandos,
|
||||
qtdFotografos: data.qtd_fotografos ?? selectedEvent.qtdFotografos,
|
||||
qtdRecepcionistas: data.qtd_recepcionistas ?? selectedEvent.qtdRecepcionistas,
|
||||
qtdCinegrafistas: data.qtd_cinegrafistas ?? selectedEvent.qtdCinegrafistas,
|
||||
qtdEstudios: data.qtd_estudios ?? selectedEvent.qtdEstudios,
|
||||
qtdPontosFoto: data.qtd_pontos_foto ?? selectedEvent.qtdPontosFoto,
|
||||
qtdPontosDecorados: data.qtd_pontos_decorados ?? selectedEvent.qtdPontosDecorados,
|
||||
qtdPontosLed: data.qtd_pontos_led ?? selectedEvent.qtdPontosLed,
|
||||
qtdPlataforma360: data.qtd_plataforma_360 ?? selectedEvent.qtdPlataforma360,
|
||||
name: data.observacoes_evento || selectedEvent.name,
|
||||
briefing: data.observacoes_evento || selectedEvent.briefing,
|
||||
time: data.horario || selectedEvent.time,
|
||||
};
|
||||
setSelectedEvent(updatedEvent);
|
||||
setView("details");
|
||||
// Optional: Reload page safely if critical fields changed that DataContext map didn't catch?
|
||||
// For now, trust DataContext + local state update.
|
||||
// Actually, DataContext refetch logic was "try import...", so it might be async.
|
||||
// Let's reload window to be 100% sure for the user as requested "mudei a data e não mudou".
|
||||
// setView("details"); // Already called above
|
||||
// removing window.location.reload() to maintain SPA feel
|
||||
// Reloading window to ensure total consistency with backend as fallback
|
||||
// window.location.reload(); // Commented out to try SPA update first
|
||||
} else {
|
||||
console.error("Update function not available");
|
||||
}
|
||||
|
|
@ -122,6 +136,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
date: "",
|
||||
fotId: "",
|
||||
type: "",
|
||||
company: "",
|
||||
institution: "",
|
||||
});
|
||||
const [isTeamModalOpen, setIsTeamModalOpen] = useState(false);
|
||||
const [viewingProfessional, setViewingProfessional] = useState<Professional | null>(null);
|
||||
|
|
@ -221,7 +237,10 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
fotoFaltante,
|
||||
recepFaltante,
|
||||
cineFaltante,
|
||||
profissionaisOK
|
||||
profissionaisOK,
|
||||
qtdFotografos,
|
||||
qtdRecepcionistas,
|
||||
qtdCinegrafistas
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -347,10 +366,14 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
: undefined;
|
||||
|
||||
// Extract unique values for filters
|
||||
const { availableTypes } = useMemo(() => {
|
||||
const { availableTypes, availableCompanies, availableInstitutions } = useMemo(() => {
|
||||
const types = [...new Set(myEvents.map((e) => e.type))].sort();
|
||||
const companies = [...new Set(myEvents.map((e) => e.empresa).filter(Boolean))].sort();
|
||||
const institutions = [...new Set(myEvents.map((e) => e.instituicao).filter(Boolean))].sort();
|
||||
return {
|
||||
availableTypes: types,
|
||||
availableCompanies: companies,
|
||||
availableInstitutions: institutions,
|
||||
};
|
||||
}, [myEvents]);
|
||||
|
||||
|
|
@ -384,14 +407,16 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
String(e.fot || "").toLowerCase().includes(advancedFilters.fotId.toLowerCase());
|
||||
const matchesType =
|
||||
!advancedFilters.type || e.type === advancedFilters.type;
|
||||
const matchesCompany =
|
||||
!advancedFilters.company || e.empresa === advancedFilters.company;
|
||||
const matchesInstitution =
|
||||
!advancedFilters.institution || e.instituicao === advancedFilters.institution;
|
||||
|
||||
return (
|
||||
matchesSearch && matchesStatus && matchesDate && matchesFot && matchesType
|
||||
matchesSearch && matchesStatus && matchesDate && matchesFot && matchesType && matchesCompany && matchesInstitution
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Keep selectedEvent in sync with global events state
|
||||
useEffect(() => {
|
||||
if (selectedEvent) {
|
||||
|
|
@ -690,6 +715,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
filters={advancedFilters}
|
||||
onFilterChange={setAdvancedFilters}
|
||||
availableTypes={availableTypes}
|
||||
availableCompanies={availableCompanies}
|
||||
availableInstitutions={availableInstitutions}
|
||||
/>
|
||||
|
||||
{/* Results Count */}
|
||||
|
|
@ -1438,26 +1465,78 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
{isTeamModalOpen && selectedEvent && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-[#492E61] to-[#5a3a7a] p-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">
|
||||
Gerenciar Equipe
|
||||
</h2>
|
||||
<p className="text-white/80 text-sm">
|
||||
{selectedEvent.name} -{" "}
|
||||
{new Date(
|
||||
selectedEvent.date + "T00:00:00"
|
||||
).toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
{/* Header do Modal */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif font-bold text-brand-black">
|
||||
Gerenciar Equipe
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{selectedEvent.name} - {new Date(selectedEvent.date + 'T00:00:00').toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeTeamModal}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resource Summary Bar */}
|
||||
<div className="bg-blue-50 px-6 py-3 border-b border-blue-100 flex flex-wrap gap-4 md:gap-8 overflow-x-auto">
|
||||
{(() => {
|
||||
const stats = calculateTeamStatus(selectedEvent);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 min-w-fit">
|
||||
<span className="text-xs font-bold text-blue-800 uppercase tracking-wider">Fotógrafos:</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-sm font-bold ${stats.fotoFaltante > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{stats.acceptedFotografos}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600">/ {stats.qtdFotografos}</span>
|
||||
{stats.fotoFaltante === 0 ? (
|
||||
<CheckCircle size={14} className="text-green-500 ml-1" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="text-red-500 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-fit">
|
||||
<span className="text-xs font-bold text-blue-800 uppercase tracking-wider">Cinegrafistas:</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-sm font-bold ${stats.cineFaltante > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{stats.acceptedCinegrafistas}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600">/ {stats.qtdCinegrafistas}</span>
|
||||
{stats.cineFaltante === 0 ? (
|
||||
<CheckCircle size={14} className="text-green-500 ml-1" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="text-red-500 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-fit">
|
||||
<span className="text-xs font-bold text-blue-800 uppercase tracking-wider">Recepcionistas:</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-sm font-bold ${stats.recepFaltante > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{stats.acceptedRecepcionistas}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600">/ {stats.qtdRecepcionistas}</span>
|
||||
{stats.recepFaltante === 0 ? (
|
||||
<CheckCircle size={14} className="text-green-500 ml-1" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="text-red-500 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
onClick={closeTeamModal}
|
||||
className="text-white hover:bg-white/20 rounded-full p-2 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
|
|
|
|||
Loading…
Reference in a new issue