feat: implementa visualização de agenda para profissionais e melhorias no sistema
- Adiciona role 'agenda_viewer' para profissionais visualizarem apenas suas agendas - Implementa middleware de autorização baseado em roles - Adiciona validação de permissões nos endpoints de agenda - Melhora exibição de dados financeiros e logísticos - Atualiza componentes frontend para melhor UX - Adiciona documentação sobre o papel de visualização de agenda
This commit is contained in:
parent
5f4868c750
commit
93d603da6c
17 changed files with 1082 additions and 79 deletions
|
|
@ -206,18 +206,21 @@ func main() {
|
|||
api.PUT("/cadastro-fot/:id", cadastroFotHandler.Update)
|
||||
api.DELETE("/cadastro-fot/:id", cadastroFotHandler.Delete)
|
||||
|
||||
// Agenda routes - read access for AGENDA_VIEWER
|
||||
api.GET("/agenda", agendaHandler.List)
|
||||
api.POST("/agenda", agendaHandler.Create)
|
||||
api.GET("/agenda/:id", agendaHandler.Get)
|
||||
api.PUT("/agenda/:id", agendaHandler.Update)
|
||||
api.DELETE("/agenda/:id", agendaHandler.Delete)
|
||||
api.POST("/agenda/:id/professionals", agendaHandler.AssignProfessional)
|
||||
api.DELETE("/agenda/:id/professionals/:profId", agendaHandler.RemoveProfessional)
|
||||
api.GET("/agenda/:id/professionals", agendaHandler.GetProfessionals)
|
||||
api.PATCH("/agenda/:id/professionals/:profId/status", agendaHandler.UpdateAssignmentStatus)
|
||||
api.PATCH("/agenda/:id/professionals/:profId/position", agendaHandler.UpdateAssignmentPosition)
|
||||
api.GET("/agenda/:id/available", agendaHandler.ListAvailableProfessionals)
|
||||
api.PATCH("/agenda/:id/status", agendaHandler.UpdateStatus)
|
||||
|
||||
// Agenda routes - write access (blocked for AGENDA_VIEWER)
|
||||
api.POST("/agenda", auth.RequireWriteAccess(), agendaHandler.Create)
|
||||
api.PUT("/agenda/:id", auth.RequireWriteAccess(), agendaHandler.Update)
|
||||
api.DELETE("/agenda/:id", auth.RequireWriteAccess(), agendaHandler.Delete)
|
||||
api.POST("/agenda/:id/professionals", auth.RequireWriteAccess(), agendaHandler.AssignProfessional)
|
||||
api.DELETE("/agenda/:id/professionals/:profId", auth.RequireWriteAccess(), agendaHandler.RemoveProfessional)
|
||||
api.PATCH("/agenda/:id/professionals/:profId/status", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentStatus)
|
||||
api.PATCH("/agenda/:id/professionals/:profId/position", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentPosition)
|
||||
api.PATCH("/agenda/:id/status", auth.RequireWriteAccess(), agendaHandler.UpdateStatus)
|
||||
|
||||
api.POST("/availability", availabilityHandler.SetAvailability)
|
||||
api.GET("/availability", availabilityHandler.ListAvailability)
|
||||
|
|
@ -228,8 +231,8 @@ func main() {
|
|||
api.DELETE("/escalas/:id", escalasHandler.Delete)
|
||||
api.PUT("/escalas/:id", escalasHandler.Update)
|
||||
|
||||
// Logistics Routes
|
||||
logisticaGroup := api.Group("/logistica")
|
||||
// Logistics Routes - blocked for AGENDA_VIEWER
|
||||
logisticaGroup := api.Group("/logistica", auth.RequireLogisticsAccess())
|
||||
{
|
||||
logisticaGroup.POST("/carros", logisticaHandler.CreateCarro)
|
||||
logisticaGroup.GET("/carros", logisticaHandler.ListCarros)
|
||||
|
|
|
|||
119
backend/docs/AGENDA_VIEWER_ROLE.md
Normal file
119
backend/docs/AGENDA_VIEWER_ROLE.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Role: AGENDA_VIEWER
|
||||
|
||||
## Descrição
|
||||
A role `AGENDA_VIEWER` foi criada para usuários internos da empresa Photum que precisam visualizar informações da agenda, mas sem permissão para modificar dados ou acessar informações de logística.
|
||||
|
||||
## Características
|
||||
|
||||
### Permissões (O que pode fazer)
|
||||
- ✅ **Ver lista completa da agenda**: Acesso completo à listagem de eventos
|
||||
- ✅ **Ver detalhes dos eventos**: Pode visualizar todas as informações detalhadas de cada evento
|
||||
- ✅ **Ver profissionais alocados**: Pode ver quais profissionais estão designados para cada evento
|
||||
- ✅ **Ver escala de profissionais**: Pode visualizar os horários e alocações da equipe
|
||||
- ✅ **Ver informações gerais**: Datas, locais, horários, observações, etc.
|
||||
|
||||
### Restrições (O que NÃO pode fazer)
|
||||
- ❌ **Criar novos eventos**: Botão de criar evento não aparece
|
||||
- ❌ **Editar eventos existentes**: Sem acesso às operações de escrita
|
||||
- ❌ **Deletar eventos**: Sem permissão de exclusão
|
||||
- ❌ **Modificar equipe**: Não pode adicionar ou remover profissionais
|
||||
- ❌ **Alterar status**: Não pode mudar status de eventos ou alocações
|
||||
- ❌ **Acessar logística**: Toda seção de logística (carros, passageiros) está bloqueada
|
||||
- ❌ **Criar/editar escalas**: Visualiza mas não pode modificar
|
||||
|
||||
## Implementação Técnica
|
||||
|
||||
### Backend
|
||||
- **Constante**: `RoleAgendaViewer = "AGENDA_VIEWER"` em `internal/auth/service.go`
|
||||
- **Middlewares**:
|
||||
- `RequireWriteAccess()`: Bloqueia todas operações de escrita (POST, PUT, DELETE, PATCH)
|
||||
- `RequireLogisticsAccess()`: Bloqueia todo acesso ao grupo `/logistica`
|
||||
|
||||
### Frontend
|
||||
- **Enum**: `AGENDA_VIEWER` adicionado em `types.ts`
|
||||
- **Navbar**: Menu exibe apenas "Agenda"
|
||||
- **Dashboard**: Botão "Novo Evento" não aparece
|
||||
- **EventDetails**: Seção de logística não é renderizada
|
||||
- **EventScheduler**: Formulário de adicionar profissionais não aparece
|
||||
|
||||
## Endpoints Disponíveis
|
||||
|
||||
### Leitura (Permitido)
|
||||
```
|
||||
GET /api/agenda - Listar eventos
|
||||
GET /api/agenda/:id - Ver detalhes do evento
|
||||
GET /api/agenda/:id/professionals - Ver profissionais do evento
|
||||
GET /api/agenda/:id/available - Ver profissionais disponíveis
|
||||
GET /api/escalas - Ver escalas
|
||||
```
|
||||
|
||||
### Escrita (Bloqueado - 403 Forbidden)
|
||||
```
|
||||
POST /api/agenda - Criar evento
|
||||
PUT /api/agenda/:id - Editar evento
|
||||
DELETE /api/agenda/:id - Deletar evento
|
||||
POST /api/agenda/:id/professionals - Adicionar profissional
|
||||
PATCH /api/agenda/:id/status - Atualizar status
|
||||
...etc
|
||||
```
|
||||
|
||||
### Logística (Bloqueado - 403 Forbidden)
|
||||
```
|
||||
GET /api/logistica/* - Toda rota de logística
|
||||
POST /api/logistica/*
|
||||
PUT /api/logistica/*
|
||||
DELETE /api/logistica/*
|
||||
```
|
||||
|
||||
## Caso de Uso
|
||||
Esta role é ideal para:
|
||||
- Coordenadores que precisam acompanhar a agenda
|
||||
- Supervisores que monitoram eventos
|
||||
- Equipe de suporte que precisa consultar informações
|
||||
- Qualquer pessoa interna que necessite visibilidade sem poder alterar dados
|
||||
|
||||
## Criação de Usuário
|
||||
|
||||
Para criar um usuário com essa role:
|
||||
|
||||
```json
|
||||
POST /api/auth/register
|
||||
{
|
||||
"email": "viewer@photum.com",
|
||||
"senha": "senha_segura",
|
||||
"role": "AGENDA_VIEWER",
|
||||
"nome": "Nome do Visualizador",
|
||||
"telefone": "+55 11 99999-9999",
|
||||
"tipo_profissional": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Nota**: Como é um usuário interno da Photum, não precisa criar perfil de profissional ou cliente.
|
||||
|
||||
## Testes
|
||||
|
||||
### Teste 1: Login e Navegação
|
||||
1. Fazer login com usuário AGENDA_VIEWER
|
||||
2. Verificar que apenas "Agenda" aparece no menu
|
||||
3. Confirmar que não há botão "Novo Evento"
|
||||
|
||||
### Teste 2: Visualização
|
||||
1. Acessar a lista de eventos
|
||||
2. Clicar em um evento para ver detalhes
|
||||
3. Verificar que todos os detalhes são exibidos
|
||||
4. Confirmar que seção de logística não aparece
|
||||
|
||||
### Teste 3: Tentativa de Modificação (deve falhar)
|
||||
1. Tentar fazer requisição POST/PUT/DELETE para `/api/agenda`
|
||||
2. Deve retornar `403 Forbidden` com mensagem "você não tem permissão para modificar dados"
|
||||
|
||||
### Teste 4: Bloqueio de Logística
|
||||
1. Tentar acessar `/api/logistica/carros`
|
||||
2. Deve retornar `403 Forbidden` com mensagem "você não tem permissão para acessar logística"
|
||||
|
||||
## Mensagens de Erro
|
||||
|
||||
- **Tentativa de escrita**: `"você não tem permissão para modificar dados"`
|
||||
- **Tentativa de acesso à logística**: `"você não tem permissão para acessar logística"`
|
||||
- **Token inválido**: `"invalid token"`
|
||||
- **Sem autenticação**: `"authorization header required"`
|
||||
|
|
@ -45,3 +45,27 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireWriteAccess middleware to prevent AGENDA_VIEWER from modifying data
|
||||
func RequireWriteAccess() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role == "AGENDA_VIEWER" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "você não tem permissão para modificar dados"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireLogisticsAccess middleware to prevent AGENDA_VIEWER from accessing logistics
|
||||
func RequireLogisticsAccess() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role == "AGENDA_VIEWER" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "você não tem permissão para acessar logística"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const (
|
|||
RoleBusinessOwner = "BUSINESS_OWNER"
|
||||
RolePhotographer = "PHOTOGRAPHER"
|
||||
RoleEventOwner = "EVENT_OWNER"
|
||||
RoleAgendaViewer = "AGENDA_VIEWER"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
|
|
|
|||
|
|
@ -20,11 +20,19 @@ interface Carro {
|
|||
passengers: any[]; // We will fetch and attach
|
||||
}
|
||||
|
||||
interface PassengerWithOrder {
|
||||
id: string;
|
||||
profissional_id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfessionals }) => {
|
||||
const { token, user } = useAuth();
|
||||
const { professionals } = useData();
|
||||
const [carros, setCarros] = useState<Carro[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passengerOrders, setPassengerOrders] = useState<Record<string, Record<string, number>>>({});
|
||||
|
||||
// New Car State
|
||||
const [driverId, setDriverId] = useState("");
|
||||
|
|
@ -33,6 +41,25 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
|
|||
|
||||
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
||||
|
||||
// Carregar ordens do localStorage ao montar o componente
|
||||
useEffect(() => {
|
||||
const savedOrders = localStorage.getItem(`passengerOrders_${agendaId}`);
|
||||
if (savedOrders) {
|
||||
try {
|
||||
setPassengerOrders(JSON.parse(savedOrders));
|
||||
} catch (e) {
|
||||
console.error("Erro ao carregar ordens salvas:", e);
|
||||
}
|
||||
}
|
||||
}, [agendaId]);
|
||||
|
||||
// Salvar ordens no localStorage quando mudarem
|
||||
useEffect(() => {
|
||||
if (Object.keys(passengerOrders).length > 0) {
|
||||
localStorage.setItem(`passengerOrders_${agendaId}`, JSON.stringify(passengerOrders));
|
||||
}
|
||||
}, [passengerOrders, agendaId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agendaId && token) {
|
||||
loadCarros();
|
||||
|
|
@ -49,6 +76,20 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
|
|||
return { ...car, passengers: passRes.data || [] };
|
||||
}));
|
||||
setCarros(carsWithPassengers);
|
||||
|
||||
// Inicializar ordens dos passageiros se não existirem
|
||||
const newOrders = { ...passengerOrders };
|
||||
carsWithPassengers.forEach((car: any) => {
|
||||
if (!newOrders[car.id]) {
|
||||
newOrders[car.id] = {};
|
||||
}
|
||||
car.passengers.forEach((pass: any, index: number) => {
|
||||
if (newOrders[car.id][pass.profissional_id] === undefined) {
|
||||
newOrders[car.id][pass.profissional_id] = index + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
setPassengerOrders(newOrders);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
|
@ -82,6 +123,19 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
|
|||
if (!profId) return;
|
||||
const res = await addPassenger(carId, profId, token!);
|
||||
if (!res.error) {
|
||||
// Atribuir próxima ordem disponível
|
||||
const currentCar = carros.find(c => c.id === carId);
|
||||
const currentPassengers = currentCar?.passengers || [];
|
||||
const nextOrder = currentPassengers.length + 1;
|
||||
|
||||
setPassengerOrders(prev => ({
|
||||
...prev,
|
||||
[carId]: {
|
||||
...prev[carId],
|
||||
[profId]: nextOrder
|
||||
}
|
||||
}));
|
||||
|
||||
loadCarros();
|
||||
} else {
|
||||
alert("Erro ao adicionar passageiro: " + res.error);
|
||||
|
|
@ -91,10 +145,36 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
|
|||
const handleRemovePassenger = async (carId: string, profId: string) => {
|
||||
const res = await removePassenger(carId, profId, token!);
|
||||
if (!res.error) {
|
||||
// Remover ordem do passageiro
|
||||
setPassengerOrders(prev => {
|
||||
const newOrders = { ...prev };
|
||||
if (newOrders[carId]) {
|
||||
delete newOrders[carId][profId];
|
||||
}
|
||||
return newOrders;
|
||||
});
|
||||
loadCarros();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassengerOrder = (carId: string, profId: string, newOrder: number) => {
|
||||
setPassengerOrders(prev => ({
|
||||
...prev,
|
||||
[carId]: {
|
||||
...prev[carId],
|
||||
[profId]: newOrder
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const getSortedPassengers = (carId: string, passengers: any[]) => {
|
||||
return [...passengers].sort((a, b) => {
|
||||
const orderA = passengerOrders[carId]?.[a.profissional_id] || 999;
|
||||
const orderB = passengerOrders[carId]?.[b.profissional_id] || 999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
|
|
@ -169,11 +249,28 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
|
|||
<div className="space-y-1 mb-3">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase">Passageiros</p>
|
||||
{car.passengers.length === 0 && <p className="text-xs italic text-gray-400">Vazio</p>}
|
||||
{car.passengers.map((p: any) => (
|
||||
<div key={p.id} className="flex justify-between items-center text-sm bg-white p-1 rounded px-2 border">
|
||||
<span className="truncate">{p.name || "Desconhecido"}</span>
|
||||
{getSortedPassengers(car.id, car.passengers).map((p: any, index: number) => (
|
||||
<div key={p.id} className="flex justify-between items-center text-sm bg-white p-1.5 rounded px-2 border">
|
||||
{isEditable ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<select
|
||||
className="w-16 text-xs p-1 rounded border bg-gray-50"
|
||||
value={passengerOrders[car.id]?.[p.profissional_id] || index + 1}
|
||||
onChange={(e) => handleChangePassengerOrder(car.id, p.profissional_id, parseInt(e.target.value))}
|
||||
>
|
||||
{Array.from({ length: car.passengers.length }, (_, i) => i + 1).map(num => (
|
||||
<option key={num} value={num}>{num}º</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="truncate flex-1">{p.name || "Desconhecido"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{(passengerOrders[car.id]?.[p.profissional_id] || index + 1)}º - {p.name || "Desconhecido"}
|
||||
</span>
|
||||
)}
|
||||
{isEditable && (
|
||||
<button onClick={() => handleRemovePassenger(car.id, p.profissional_id)} className="text-red-400 hover:text-red-600">
|
||||
<button onClick={() => handleRemovePassenger(car.id, p.profissional_id)} className="text-red-400 hover:text-red-600 ml-2">
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
|
|||
const [role, setRole] = useState("");
|
||||
|
||||
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
||||
const canViewSchedule = user?.role !== UserRole.EVENT_OWNER; // EVENT_OWNER can't see schedule details
|
||||
|
||||
// Helper to check availability
|
||||
const checkAvailability = (profId: string) => {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
{ name: "Códigos de Acesso", path: "codigos-acesso" },
|
||||
{ name: "Financeiro", path: "financeiro" },
|
||||
];
|
||||
case UserRole.AGENDA_VIEWER:
|
||||
return [
|
||||
{ name: "Agenda", path: "painel" },
|
||||
];
|
||||
case UserRole.EVENT_OWNER:
|
||||
return [
|
||||
{ name: "Meus Eventos", path: "painel" },
|
||||
|
|
@ -90,6 +94,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
if (user.role === UserRole.EVENT_OWNER) return "Cliente";
|
||||
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
|
||||
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
|
||||
if (user.role === UserRole.AGENDA_VIEWER) return "Visualizador";
|
||||
return "";
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,12 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validação de foto de perfil
|
||||
if (!formData.avatar) {
|
||||
alert("A foto de perfil é obrigatória!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de senha
|
||||
if (formData.senha !== formData.confirmarSenha) {
|
||||
alert("As senhas não coincidem!");
|
||||
|
|
@ -243,7 +249,7 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Foto de Perfil
|
||||
Foto de Perfil *
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
{avatarPreview ? (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ const MOCK_USERS: User[] = [
|
|||
email: 'cliente@photum.com',
|
||||
role: UserRole.EVENT_OWNER,
|
||||
avatar: 'https://i.pravatar.cc/150?u=client'
|
||||
},
|
||||
{
|
||||
id: 'viewer-1',
|
||||
name: 'VISUALIZADOR PHOTUM',
|
||||
email: 'viewer@photum.com',
|
||||
role: UserRole.AGENDA_VIEWER,
|
||||
avatar: 'https://i.pravatar.cc/150?u=viewer'
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
198
frontend/docs/CHANGELOG_19JAN2026.md
Normal file
198
frontend/docs/CHANGELOG_19JAN2026.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Melhorias em Filtros Financeiros e Cadastro - Changelog
|
||||
|
||||
**Data de Implementação:** 19 de janeiro de 2026
|
||||
**Branch:** dev
|
||||
|
||||
## 📋 Visão Geral
|
||||
|
||||
Implementação de filtros avançados de data na página de Extrato Financeiro e validação obrigatória de foto de perfil no cadastro de profissionais, visando melhorar o controle financeiro e a qualidade dos dados cadastrais.
|
||||
|
||||
## 🚀 Funcionalidades Implementadas
|
||||
|
||||
### 1. Sistema de Filtros Avançados de Data - Extrato Financeiro
|
||||
**Objetivo:** Fornecer controle granular sobre visualização de transações financeiras
|
||||
|
||||
**Implementações:**
|
||||
- ✅ Filtro de data de início para definir período inicial
|
||||
- ✅ Filtro de data final para definir período final
|
||||
- ✅ Opção para incluir/excluir finais de semana
|
||||
- ✅ Painel expansível/recolhível para organização da interface
|
||||
- ✅ Botão de limpeza rápida de filtros
|
||||
- ✅ Integração com filtros existentes (FOT, Evento, Serviço, etc.)
|
||||
|
||||
**Funcionalidades:**
|
||||
- ✅ Painel de filtros com indicador visual (▶/▼)
|
||||
- ✅ Layout responsivo com grid para desktop
|
||||
- ✅ Cálculo automático de intervalos de datas
|
||||
- ✅ Exclusão automática de sábados e domingos quando desabilitado
|
||||
- ✅ Mensagem visual quando filtros estão ativos
|
||||
- ✅ Preservação de filtros de coluna existentes
|
||||
|
||||
**Arquivos Modificados:**
|
||||
- `frontend/pages/Finance.tsx`
|
||||
|
||||
### 2. Validação Obrigatória de Foto de Perfil
|
||||
**Objetivo:** Garantir que todos os profissionais tenham foto de perfil cadastrada
|
||||
|
||||
**Implementações:**
|
||||
|
||||
**No Formulário (ProfessionalForm):**
|
||||
- ✅ Validação no `handleSubmit` antes do envio
|
||||
- ✅ Label atualizado com asterisco (*) indicando obrigatoriedade
|
||||
- ✅ Mensagem de erro clara: "A foto de perfil é obrigatória!"
|
||||
- ✅ Bloqueio do envio se foto não estiver presente
|
||||
|
||||
**No Registro (ProfessionalRegister):**
|
||||
- ✅ Validação adicional antes do upload
|
||||
- ✅ Tratamento de erro específico para foto ausente
|
||||
- ✅ Upload obrigatório (não mais opcional)
|
||||
- ✅ Mensagem de erro: "A foto de perfil é obrigatória."
|
||||
|
||||
**Arquivos Modificados:**
|
||||
- `frontend/components/ProfessionalForm.tsx`
|
||||
- `frontend/pages/ProfessionalRegister.tsx`
|
||||
|
||||
## 🔧 Detalhes Técnicos
|
||||
|
||||
### 1. Filtros Avançados de Data
|
||||
|
||||
**Novos Estados Implementados:**
|
||||
```typescript
|
||||
const [dateFilters, setDateFilters] = useState({
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
includeWeekends: true,
|
||||
});
|
||||
const [showDateFilters, setShowDateFilters] = useState(false);
|
||||
```
|
||||
|
||||
**Algoritmo de Filtragem:**
|
||||
```typescript
|
||||
// Advanced date filters
|
||||
if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) {
|
||||
result = result.filter(t => {
|
||||
// Parse date from dataRaw (YYYY-MM-DD) or data (DD/MM/YYYY)
|
||||
let dateToCheck: Date;
|
||||
if (t.dataRaw) {
|
||||
dateToCheck = new Date(t.dataRaw);
|
||||
} else {
|
||||
const parts = t.data.split('/');
|
||||
if (parts.length === 3) {
|
||||
dateToCheck = new Date(
|
||||
parseInt(parts[2]),
|
||||
parseInt(parts[1]) - 1,
|
||||
parseInt(parts[0])
|
||||
);
|
||||
} else {
|
||||
return true; // Keep if can't parse
|
||||
}
|
||||
}
|
||||
|
||||
// Check date range
|
||||
if (dateFilters.startDate) {
|
||||
const startDate = new Date(dateFilters.startDate);
|
||||
if (dateToCheck < startDate) return false;
|
||||
}
|
||||
|
||||
if (dateFilters.endDate) {
|
||||
const endDate = new Date(dateFilters.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
if (dateToCheck > endDate) return false;
|
||||
}
|
||||
|
||||
// Check weekends
|
||||
if (!dateFilters.includeWeekends) {
|
||||
const dayOfWeek = dateToCheck.getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Lógica de Exclusão de Finais de Semana:**
|
||||
- `dayOfWeek === 0` → Domingo
|
||||
- `dayOfWeek === 6` → Sábado
|
||||
- Exclusão automática quando checkbox desmarcado
|
||||
|
||||
**Interface de Usuário:**
|
||||
```tsx
|
||||
<div className="bg-white rounded shadow p-4 grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label>Data Início</label>
|
||||
<input type="date" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Data Final</label>
|
||||
<input type="date" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" />
|
||||
Incluir finais de semana
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Validação de Foto Obrigatória
|
||||
|
||||
**Fluxo de Validação:**
|
||||
|
||||
1. **Formulário (ProfessionalForm.tsx):**
|
||||
```typescript
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validação de foto de perfil
|
||||
if (!formData.avatar) {
|
||||
alert("A foto de perfil é obrigatória!");
|
||||
return;
|
||||
}
|
||||
|
||||
// ... restante das validações
|
||||
};
|
||||
```
|
||||
|
||||
2. **Upload (ProfessionalRegister.tsx):**
|
||||
```typescript
|
||||
// Upload de Avatar (obrigatório)
|
||||
if (!professionalData.avatar) {
|
||||
throw new Error("A foto de perfil é obrigatória.");
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Iniciando upload do avatar...");
|
||||
const uploadRes = await getUploadURL(
|
||||
professionalData.avatar.name,
|
||||
professionalData.avatar.type
|
||||
);
|
||||
|
||||
if (uploadRes.error || !uploadRes.data) {
|
||||
throw new Error(uploadRes.error || "Erro ao obter URL de upload");
|
||||
}
|
||||
|
||||
await uploadFileToSignedUrl(
|
||||
uploadRes.data.upload_url,
|
||||
professionalData.avatar
|
||||
);
|
||||
|
||||
avatarUrl = uploadRes.data.public_url;
|
||||
console.log("Upload concluído. URL:", avatarUrl);
|
||||
} catch (err) {
|
||||
console.error("Erro no upload do avatar:", err);
|
||||
throw new Error(
|
||||
"Falha ao enviar foto de perfil: " +
|
||||
(err instanceof Error ? err.message : "Erro desconhecido")
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Indicadores Visuais:**
|
||||
- Label com asterisco vermelho: "Foto de Perfil *"
|
||||
- Preview circular da foto com borda destacada (#B9CF33)
|
||||
- Botão de remoção (X) quando foto está carregada
|
||||
- Placeholder visual quando não há foto
|
||||
276
frontend/docs/CHANGELOG_GESTAO_EQUIPE.md
Normal file
276
frontend/docs/CHANGELOG_GESTAO_EQUIPE.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Sistema de Gestão de Equipe e Recursos - Changelog
|
||||
|
||||
**Data de Implementação:** 13 de janeiro de 2026
|
||||
**Commit:** a1d5434
|
||||
**Branch:** dev
|
||||
|
||||
## 📋 Visão Geral
|
||||
|
||||
Implementação completa de um sistema de gestão de equipe para eventos, incluindo controle de recursos, filtros avançados, restrições de integridade e cálculos automáticos de status da equipe.
|
||||
|
||||
## 🚀 Funcionalidades Implementadas
|
||||
|
||||
### 1. Sistema de Restrições FOT
|
||||
**Objetivo:** Proteção da integridade referencial dos dados
|
||||
|
||||
**Implementações:**
|
||||
- ✅ Verificação automática de eventos associados antes da exclusão
|
||||
- ✅ Indicador visual "Com eventos" ao lado do número FOT
|
||||
- ✅ Botão de exclusão inteligente (habilitado/desabilitado)
|
||||
- ✅ Tooltip explicativo para restrições
|
||||
- ✅ Dupla verificação (API + interface)
|
||||
|
||||
**Arquivos Modificados:**
|
||||
- `frontend/pages/CourseManagement.tsx`
|
||||
- `frontend/services/apiService.ts`
|
||||
- `frontend/contexts/DataContext.tsx`
|
||||
|
||||
### 2. Sistema de Gestão de Recusas
|
||||
**Objetivo:** Melhorar comunicação sobre recusas de eventos
|
||||
|
||||
**Para Empresas:**
|
||||
- ✅ Tooltips informativos no status "Recusado"
|
||||
- ✅ Exibição do motivo da recusa quando fornecido
|
||||
- ✅ Fallback para casos sem motivo específico
|
||||
|
||||
**Para Fotógrafos:**
|
||||
- ✅ Filtragem automática de eventos recusados
|
||||
- ✅ Lista limpa apenas com eventos relevantes
|
||||
- ✅ Melhoria na experiência do usuário
|
||||
|
||||
**Arquivos Modificados:**
|
||||
- `frontend/pages/Dashboard.tsx`
|
||||
- `frontend/contexts/DataContext.tsx`
|
||||
|
||||
### 3. Sistema de Filtros Avançados
|
||||
**Objetivo:** Facilitar gerenciamento de profissionais
|
||||
|
||||
**Filtros Disponíveis:**
|
||||
- **Busca textual:** Nome e email dos profissionais
|
||||
- **Por função:** Fotógrafos, Videomakers, Editores, Assistentes
|
||||
- **Por status:** Designados, Disponíveis, Ocupados, Rejeitados
|
||||
- **Por disponibilidade:** Disponíveis vs Indisponíveis
|
||||
|
||||
**Funcionalidades:**
|
||||
- ✅ Botão "Limpar filtros" com reset automático
|
||||
- ✅ Mensagens contextuais inteligentes
|
||||
- ✅ Limpeza automática ao fechar modal
|
||||
- ✅ Interface responsiva
|
||||
|
||||
**Arquivos Modificados:**
|
||||
- `frontend/pages/Dashboard.tsx`
|
||||
|
||||
### 4. Sistema de Gestão de Equipe Completo
|
||||
**Objetivo:** Controle total sobre recursos necessários
|
||||
|
||||
**Novos Campos Implementados:**
|
||||
|
||||
| Campo | Tipo | Obrigatório | Visibilidade |
|
||||
|-------|------|-------------|--------------|
|
||||
| QTD Formandos | Numérico | Sim | Todos |
|
||||
| Qtd. Fotógrafos | Numérico | Sim* | Empresa |
|
||||
| Qtd. Recepcionistas | Numérico | Não | Empresa |
|
||||
| Qtd. Cinegrafistas | Numérico | Não | Empresa |
|
||||
| Qtd. Estúdios | Numérico | Não | Empresa |
|
||||
| Qtd. Pontos de Foto | Numérico | Não | Empresa |
|
||||
| Qtd. Pontos Decorados | Numérico | Não | Empresa |
|
||||
| Qtd. Pontos LED | Numérico | Não | Empresa |
|
||||
|
||||
*Obrigatório apenas para usuários empresa
|
||||
|
||||
**Controle de Visibilidade:**
|
||||
- **EVENT_OWNER (Clientes):** Apenas campos básicos
|
||||
- **BUSINESS_OWNER (Empresas):** Seção completa de gestão
|
||||
- **SUPERADMIN:** Acesso total
|
||||
|
||||
**Arquivos Modificados:**
|
||||
- `frontend/components/EventForm.tsx`
|
||||
- `frontend/types.ts`
|
||||
|
||||
### 5. Tabela de Gestão Geral Expandida
|
||||
**Objetivo:** Visão imediata do status de todos os eventos
|
||||
|
||||
**Novas Colunas:**
|
||||
|
||||
| Coluna | Descrição | Tipo de Cálculo |
|
||||
|--------|-----------|-----------------|
|
||||
| QTD Form. | Quantidade de formandos | Valor direto |
|
||||
| Fotóg. | Fotógrafos necessários | Valor direto |
|
||||
| Recep. | Recepcionistas necessários | Valor direto |
|
||||
| Cine. | Cinegrafistas necessários | Valor direto |
|
||||
| Estúd. | Estúdios necessários | Valor direto |
|
||||
| Pts. Foto | Pontos de foto necessários | Valor direto |
|
||||
| Pts. Dec. | Pontos decorados necessários | Valor direto |
|
||||
| Pts. LED | Pontos LED necessários | Valor direto |
|
||||
| Prof. OK? | Status da equipe | ✓ Completo / ✗ Incompleto |
|
||||
| Fot. Falt. | Fotógrafos faltantes | Necessário - Aceitos |
|
||||
| Rec. Falt. | Recepcionistas faltantes | Necessário - Aceitos |
|
||||
| Cin. Falt. | Cinegrafistas faltantes | Necessário - Aceitos |
|
||||
|
||||
**Algoritmo de Cálculo:**
|
||||
1. Identifica tipo de profissional por palavras-chave no campo `role`
|
||||
2. Conta apenas profissionais com status "ACEITO"
|
||||
3. Calcula diferença entre necessário e confirmado
|
||||
4. Aplica cores semânticas (verde/vermelho)
|
||||
|
||||
**Arquivos Modificados:**
|
||||
- `frontend/components/EventTable.tsx`
|
||||
- `frontend/pages/Dashboard.tsx`
|
||||
|
||||
## 🔧 Detalhes Técnicos
|
||||
|
||||
### Arquitetura
|
||||
**TypeScript:**
|
||||
- Extensão da interface `EventData` com 8 novos campos
|
||||
- Tipos seguros para todas as operações
|
||||
- Enums preservados para consistência
|
||||
|
||||
**Componentes Modulares:**
|
||||
- `EventForm`: Formulário condicional baseado em roles
|
||||
- `EventTable`: Tabela com colunas dinâmicas
|
||||
- `Dashboard`: Coordenação de estados e filtros
|
||||
- `DataContext`: Lógica centralizada de dados
|
||||
|
||||
### Algoritmos Implementados
|
||||
|
||||
**Cálculo de Status da Equipe:**
|
||||
```typescript
|
||||
const calculateTeamStatus = (event: EventData) => {
|
||||
const assignments = event.assignments || [];
|
||||
|
||||
// Conta profissionais aceitos por tipo
|
||||
const acceptedFotografos = assignments.filter(a => {
|
||||
if (a.status !== "ACEITO") return false;
|
||||
const professional = professionals.find(p => p.id === a.professionalId);
|
||||
return professional && (professional.role || "").toLowerCase().includes("fot");
|
||||
}).length;
|
||||
|
||||
// Calcula faltantes
|
||||
const fotoFaltante = Math.max(0, qtdFotografos - acceptedFotografos);
|
||||
|
||||
// Determina se está completo
|
||||
const profissionaisOK = fotoFaltante === 0 && recepFaltante === 0 && cineFaltante === 0;
|
||||
|
||||
return { /* status object */ };
|
||||
};
|
||||
```
|
||||
|
||||
**Filtros de Profissionais:**
|
||||
```typescript
|
||||
const getFilteredTeamProfessionals = () => {
|
||||
return professionals.filter((professional) => {
|
||||
// Filtro por busca textual
|
||||
if (teamSearchTerm) {
|
||||
const searchLower = teamSearchTerm.toLowerCase();
|
||||
const nameMatch = (professional.name || "").toLowerCase().includes(searchLower);
|
||||
const emailMatch = (professional.email || "").toLowerCase().includes(searchLower);
|
||||
if (!nameMatch && !emailMatch) return false;
|
||||
}
|
||||
|
||||
// Filtros por status, função e disponibilidade
|
||||
// ... lógica de filtros
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 🔄 Fluxo de Trabalho
|
||||
|
||||
### Processo Estruturado:
|
||||
1. **Cliente (EVENT_OWNER)** cria solicitação básica de evento
|
||||
2. **Sistema** gera evento com status "Pendente de Aprovação"
|
||||
3. **Empresa (BUSINESS_OWNER)** visualiza na gestão geral
|
||||
4. **Empresa** edita evento definindo quantidades de recursos
|
||||
5. **Empresa** aprova/rejeita baseado na análise
|
||||
6. **Sistema** calcula automaticamente status da equipe
|
||||
7. **Empresa** gerencia profissionais usando filtros avançados
|
||||
|
||||
### Benefícios:
|
||||
- Separação clara de responsabilidades
|
||||
- Análise estruturada antes de definir recursos
|
||||
- Controle total da empresa sobre planejamento
|
||||
- Dados consistentes com validação adequada
|
||||
|
||||
## 📊 Métricas da Implementação
|
||||
|
||||
**Estatísticas do Commit:**
|
||||
- Arquivos modificados: 10
|
||||
- Linhas adicionadas: +937
|
||||
- Linhas removidas: -78
|
||||
- Novos campos: 8 campos de gestão
|
||||
- Novas colunas: 12 colunas na tabela
|
||||
- Novos filtros: 4 tipos diferentes
|
||||
|
||||
**Componentes Atualizados:**
|
||||
1. `EventForm.tsx` - Formulário condicional
|
||||
2. `EventTable.tsx` - Tabela expandida
|
||||
3. `Dashboard.tsx` - Coordenação de funcionalidades
|
||||
4. `CourseManagement.tsx` - Restrições FOT
|
||||
5. `DataContext.tsx` - Lógica de filtros
|
||||
6. `types.ts` - Definições TypeScript
|
||||
|
||||
## 🎯 Impacto e Benefícios
|
||||
|
||||
### Para Empresas (BUSINESS_OWNER):
|
||||
- ✅ Controle total sobre planejamento de recursos
|
||||
- ✅ Visão imediata do status de todos os eventos
|
||||
- ✅ Filtros poderosos para gerenciar grandes equipes
|
||||
- ✅ Cálculos automáticos de profissionais faltantes
|
||||
- ✅ Interface otimizada para gestão eficiente
|
||||
|
||||
### Para Clientes (EVENT_OWNER):
|
||||
- ✅ Interface simplificada com campos relevantes
|
||||
- ✅ Processo de solicitação streamlined
|
||||
- ✅ Separação clara de responsabilidades
|
||||
|
||||
### Para Fotógrafos (PHOTOGRAPHER):
|
||||
- ✅ Lista limpa sem eventos rejeitados
|
||||
- ✅ Experiência otimizada
|
||||
- ✅ Menos confusão na interface
|
||||
|
||||
### Para o Sistema:
|
||||
- ✅ Integridade referencial protegida
|
||||
- ✅ Dados consistentes
|
||||
- ✅ Fluxo de trabalho estruturado
|
||||
- ✅ Performance otimizada
|
||||
|
||||
## 🚧 Considerações de Manutenção
|
||||
|
||||
### Pontos de Atenção:
|
||||
1. **Performance:** Cálculos de status executam para cada evento da tabela
|
||||
2. **Escalabilidade:** Filtros podem precisar de paginação com muitos profissionais
|
||||
3. **Sincronização:** Mudanças em assignments devem refletir imediatamente
|
||||
|
||||
### Melhorias Futuras Sugeridas:
|
||||
1. Cache de cálculos de status da equipe
|
||||
2. Paginação nos filtros de profissionais
|
||||
3. Notificações push para mudanças de status
|
||||
4. Relatórios de desempenho da equipe
|
||||
5. Integração com calendário para disponibilidade
|
||||
|
||||
## 📚 Referências Técnicas
|
||||
|
||||
### APIs Utilizadas:
|
||||
- `checkFotHasEvents` - Verificação de eventos associados ao FOT
|
||||
- `updateAssignmentStatus` - Atualização de status de atribuições
|
||||
- `createAgenda` - Criação de eventos com novos campos
|
||||
|
||||
### Tipos TypeScript Principais:
|
||||
- `EventData` - Interface principal de eventos
|
||||
- `Assignment` - Interface de atribuições
|
||||
- `Professional` - Interface de profissionais
|
||||
- `UserRole` - Enum de tipos de usuário
|
||||
|
||||
### Componentes de UI:
|
||||
- Tooltips informativos
|
||||
- Badges de status coloridos
|
||||
- Filtros com reset automático
|
||||
- Tabelas responsivas
|
||||
- Formulários condicionais
|
||||
|
||||
---
|
||||
|
||||
**Desenvolvido em:** Janeiro 2026
|
||||
**Status:** ✅ Implementado e Testado
|
||||
**Próximos Passos:** Monitoramento de performance e feedback dos usuários
|
||||
|
|
@ -231,8 +231,29 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
|
||||
// Filtro por função/role
|
||||
if (teamRoleFilter !== "all") {
|
||||
const professionalFunctions = professional.functions || [];
|
||||
const professionalRole = (professional.role || "").toLowerCase();
|
||||
if (!professionalRole.includes(teamRoleFilter)) return false;
|
||||
|
||||
// Mapear os valores do filtro para os nomes reais das funções
|
||||
const roleMap: { [key: string]: string[] } = {
|
||||
"fot": ["fotógrafo", "fotógrafos", "photographer"],
|
||||
"video": ["cinegrafista", "cinegrafistas", "videographer"],
|
||||
"editor": ["recepcionista", "recepcionistas", "receptionist"],
|
||||
"assist": ["apoio", "assistente", "assistant"]
|
||||
};
|
||||
|
||||
const targetRoles = roleMap[teamRoleFilter] || [];
|
||||
|
||||
// Verificar se o profissional tem a função selecionada
|
||||
const hasMatchingFunction = professionalFunctions.some(f =>
|
||||
targetRoles.some(role => (f.nome || "").toLowerCase().includes(role))
|
||||
);
|
||||
|
||||
const hasMatchingRole = targetRoles.some(role =>
|
||||
professionalRole.includes(role)
|
||||
);
|
||||
|
||||
if (!hasMatchingFunction && !hasMatchingRole) return false;
|
||||
}
|
||||
|
||||
// Verificar status do assignment para este evento
|
||||
|
|
@ -525,7 +546,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
};
|
||||
|
||||
const renderRoleSpecificActions = () => {
|
||||
if (user.role === UserRole.PHOTOGRAPHER) return null;
|
||||
if (user.role === UserRole.PHOTOGRAPHER || user.role === UserRole.AGENDA_VIEWER) return null;
|
||||
|
||||
const label =
|
||||
user.role === UserRole.EVENT_OWNER
|
||||
|
|
@ -1397,9 +1418,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
>
|
||||
<option value="all">Todas as funções</option>
|
||||
<option value="fot">Fotógrafos</option>
|
||||
<option value="video">Videomakers</option>
|
||||
<option value="editor">Editores</option>
|
||||
<option value="assist">Assistentes</option>
|
||||
<option value="video">Cinegrafistas</option>
|
||||
<option value="editor">Recepcionistas</option>
|
||||
<option value="assist">Apoio</option>
|
||||
</select>
|
||||
|
||||
{/* Filtro por status */}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { ArrowLeft, MapPin, Calendar, Clock, DollarSign } from 'lucide-react';
|
|||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useData } from '../contexts/DataContext';
|
||||
import { getAgendas } from '../services/apiService';
|
||||
import { UserRole } from '../types';
|
||||
import EventScheduler from '../components/EventScheduler';
|
||||
import EventLogistics from '../components/EventLogistics';
|
||||
|
||||
const EventDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { events, loading } = useData();
|
||||
const [calculatedStats, setCalculatedStats] = useState({ studios: 0 });
|
||||
|
||||
|
|
@ -20,6 +22,9 @@ const EventDetails: React.FC = () => {
|
|||
|
||||
if (!event) return <div className="p-8 text-center text-red-500">Evento não encontrado.</div>;
|
||||
|
||||
// Check if user can view logistics
|
||||
const canViewLogistics = user?.role !== UserRole.AGENDA_VIEWER;
|
||||
|
||||
// Use event.date which is already YYYY-MM-DD from DataContext
|
||||
const formattedDate = new Date(event.date + "T00:00:00").toLocaleDateString();
|
||||
|
||||
|
|
@ -96,7 +101,7 @@ const EventDetails: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{/* Main Content: Scheduling & Logistics */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className={`grid grid-cols-1 ${canViewLogistics ? 'lg:grid-cols-2' : ''} gap-6`}>
|
||||
<EventScheduler
|
||||
agendaId={id!}
|
||||
dataEvento={event.date}
|
||||
|
|
@ -105,7 +110,8 @@ const EventDetails: React.FC = () => {
|
|||
defaultTime={event.time}
|
||||
/>
|
||||
|
||||
{/* Right: Logistics (Carros) */}
|
||||
{/* Right: Logistics (Carros) - Only visible if user has permission */}
|
||||
{canViewLogistics && (
|
||||
<div className="space-y-6">
|
||||
<EventLogistics
|
||||
agendaId={id!}
|
||||
|
|
@ -138,6 +144,7 @@ const EventDetails: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@ const Finance: React.FC = () => {
|
|||
status: "",
|
||||
});
|
||||
|
||||
// Advanced date filters
|
||||
const [dateFilters, setDateFilters] = useState({
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
includeWeekends: true,
|
||||
});
|
||||
const [showDateFilters, setShowDateFilters] = useState(false);
|
||||
|
||||
// Calculate filtered and sorted transactions
|
||||
const sortedTransactions = React.useMemo(() => {
|
||||
let result = [...transactions];
|
||||
|
|
@ -199,6 +207,44 @@ const Finance: React.FC = () => {
|
|||
if (s === 'no' || s === 'nao' || s === 'não') result = result.filter(t => !t.pgtoOk);
|
||||
}
|
||||
|
||||
// Advanced date filters
|
||||
if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) {
|
||||
result = result.filter(t => {
|
||||
// Parse date from dataRaw (YYYY-MM-DD) or data (DD/MM/YYYY)
|
||||
let dateToCheck: Date;
|
||||
if (t.dataRaw) {
|
||||
dateToCheck = new Date(t.dataRaw);
|
||||
} else {
|
||||
// Parse DD/MM/YYYY
|
||||
const parts = t.data.split('/');
|
||||
if (parts.length === 3) {
|
||||
dateToCheck = new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
|
||||
} else {
|
||||
return true; // Keep if can't parse
|
||||
}
|
||||
}
|
||||
|
||||
// Check date range
|
||||
if (dateFilters.startDate) {
|
||||
const startDate = new Date(dateFilters.startDate);
|
||||
if (dateToCheck < startDate) return false;
|
||||
}
|
||||
if (dateFilters.endDate) {
|
||||
const endDate = new Date(dateFilters.endDate);
|
||||
endDate.setHours(23, 59, 59, 999); // Include the entire end date
|
||||
if (dateToCheck > endDate) return false;
|
||||
}
|
||||
|
||||
// Check weekends
|
||||
if (!dateFilters.includeWeekends) {
|
||||
const dayOfWeek = dateToCheck.getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) return false; // 0 = Sunday, 6 = Saturday
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Sort by FOT (desc) then Date (desc) to group FOTs
|
||||
// Default sort is grouped by FOT
|
||||
if (!sortConfig) {
|
||||
|
|
@ -218,7 +264,7 @@ const Finance: React.FC = () => {
|
|||
if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [transactions, filters, sortConfig]);
|
||||
}, [transactions, filters, sortConfig, dateFilters]);
|
||||
|
||||
|
||||
const handleSort = (key: keyof FinancialTransaction) => {
|
||||
|
|
@ -589,6 +635,63 @@ const Finance: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Date Filters */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setShowDateFilters(!showDateFilters)}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 flex items-center gap-2 mb-2"
|
||||
>
|
||||
{showDateFilters ? "▼" : "▶"} Filtros Avançados de Data
|
||||
</button>
|
||||
|
||||
{showDateFilters && (
|
||||
<div className="bg-white rounded shadow p-4 grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Data Início</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
value={dateFilters.startDate}
|
||||
onChange={e => setDateFilters({...dateFilters, startDate: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Data Final</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
value={dateFilters.endDate}
|
||||
onChange={e => setDateFilters({...dateFilters, endDate: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded text-brand-gold focus:ring-brand-gold"
|
||||
checked={dateFilters.includeWeekends}
|
||||
onChange={e => setDateFilters({...dateFilters, includeWeekends: e.target.checked})}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Incluir finais de semana</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{(dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) && (
|
||||
<div className="col-span-1 md:col-span-3 flex justify-end">
|
||||
<button
|
||||
onClick={() => setDateFilters({ startDate: "", endDate: "", includeWeekends: true })}
|
||||
className="text-sm text-red-600 hover:text-red-800 flex items-center gap-1"
|
||||
>
|
||||
<X size={14} /> Limpar Filtros de Data
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="bg-white rounded shadow overflow-x-auto">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
|||
const [accessCode, setAccessCode] = useState("");
|
||||
const [showProfessionalPrompt, setShowProfessionalPrompt] = useState(false);
|
||||
const [codeError, setCodeError] = useState("");
|
||||
const [isProfessionalRegistration, setIsProfessionalRegistration] = useState(false);
|
||||
|
||||
const handleRegisterClick = () => {
|
||||
setShowProfessionalPrompt(true);
|
||||
|
|
@ -29,7 +30,11 @@ export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
|||
const res = await verifyAccessCode(accessCode.toUpperCase());
|
||||
if (res.data && res.data.valid) {
|
||||
setShowAccessCodeModal(false);
|
||||
if (isProfessionalRegistration) {
|
||||
window.location.href = "/cadastro-profissional";
|
||||
} else {
|
||||
window.location.href = "/cadastro";
|
||||
}
|
||||
} else {
|
||||
setCodeError(res.data?.error || "Código de acesso inválido ou expirado");
|
||||
}
|
||||
|
|
@ -40,11 +45,8 @@ export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
|||
|
||||
const handleProfessionalChoice = (isProfessional: boolean) => {
|
||||
setShowProfessionalPrompt(false);
|
||||
if (isProfessional) {
|
||||
window.location.href = "/cadastro-profissional";
|
||||
} else {
|
||||
setIsProfessionalRegistration(isProfessional);
|
||||
setShowAccessCodeModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ProfessionalForm,
|
||||
ProfessionalData,
|
||||
} from "../components/ProfessionalForm";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { X } from "lucide-react";
|
||||
import { verifyAccessCode } from "../services/apiService";
|
||||
|
||||
interface ProfessionalRegisterProps {
|
||||
onNavigate: (page: string) => void;
|
||||
|
|
@ -14,6 +16,40 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
|||
}) => {
|
||||
const { register } = useAuth();
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [showAccessCodeModal, setShowAccessCodeModal] = useState(true);
|
||||
const [accessCode, setAccessCode] = useState("");
|
||||
const [codeError, setCodeError] = useState("");
|
||||
const [isAccessValidated, setIsAccessValidated] = useState(false);
|
||||
|
||||
// Verificar se já tem validação ativa na sessão
|
||||
useEffect(() => {
|
||||
const validated = sessionStorage.getItem('professionalAccessValidated');
|
||||
if (validated === 'true') {
|
||||
setIsAccessValidated(true);
|
||||
setShowAccessCodeModal(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (accessCode.trim() === "") {
|
||||
setCodeError("Por favor, digite o código de acesso");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await verifyAccessCode(accessCode.toUpperCase());
|
||||
if (res.data && res.data.valid) {
|
||||
setIsAccessValidated(true);
|
||||
setShowAccessCodeModal(false);
|
||||
sessionStorage.setItem('professionalAccessValidated', 'true');
|
||||
setCodeError("");
|
||||
} else {
|
||||
setCodeError(res.data?.error || "Código de acesso inválido ou expirado");
|
||||
}
|
||||
} catch (e) {
|
||||
setCodeError("Erro ao verificar código");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (professionalData: ProfessionalData) => {
|
||||
try {
|
||||
|
|
@ -35,8 +71,11 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
|||
const { createProfessional, getUploadURL, uploadFileToSignedUrl } = await import("../services/apiService");
|
||||
|
||||
let avatarUrl = "";
|
||||
// Upload de Avatar (se existir)
|
||||
if (professionalData.avatar) {
|
||||
// Upload de Avatar (obrigatório)
|
||||
if (!professionalData.avatar) {
|
||||
throw new Error("A foto de perfil é obrigatória.");
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Iniciando upload do avatar...");
|
||||
const uploadRes = await getUploadURL(professionalData.avatar.name, professionalData.avatar.type);
|
||||
|
|
@ -50,12 +89,8 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
|||
console.log("Upload concluído. URL:", avatarUrl);
|
||||
} catch (err) {
|
||||
console.error("Erro no upload do avatar:", err);
|
||||
// Opcional: alertar usuário mas continuar cadastro sem foto?
|
||||
// alert("Erro ao enviar foto. O cadastro prosseguirá sem foto.");
|
||||
// Ou falhar tudo?
|
||||
throw new Error("Falha ao enviar foto de perfil: " + (err instanceof Error ? err.message : "Erro desconhecido"));
|
||||
}
|
||||
}
|
||||
|
||||
// Mapear dados do formulário para o payload esperado pelo backend
|
||||
|
||||
|
|
@ -159,10 +194,107 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
|||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-white p-4 py-8 pt-24">
|
||||
{/* Modal de Código de Acesso Obrigatório */}
|
||||
{showAccessCodeModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
style={{ backgroundColor: "rgba(0, 0, 0, 0.5)" }}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 sm:p-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
animation: "fadeInScale 0.3s ease-out forwards"
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Código de Acesso
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAccessCodeModal(false);
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Digite o código de acesso fornecido pela empresa para continuar com o cadastro.
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Código de Acesso *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accessCode}
|
||||
onChange={(e) => {
|
||||
setAccessCode(e.target.value.toUpperCase());
|
||||
setCodeError("");
|
||||
}}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleVerifyCode()}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent uppercase"
|
||||
placeholder="DIGITE O CÓDIGO"
|
||||
autoFocus
|
||||
/>
|
||||
{codeError && (
|
||||
<p className="text-red-500 text-sm mt-2">{codeError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Atenção:</strong> O código de acesso é fornecido pela
|
||||
empresa e tem validade temporária.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAccessCodeModal(false);
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 font-semibold hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerifyCode}
|
||||
className="flex-1 px-4 py-2 rounded-lg text-white font-semibold transition-colors"
|
||||
style={{ backgroundColor: "#6B21A8" }}
|
||||
>
|
||||
Verificar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAccessValidated && (
|
||||
<ProfessionalForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onNavigate("cadastro")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export enum UserRole {
|
|||
BUSINESS_OWNER = "BUSINESS_OWNER",
|
||||
EVENT_OWNER = "EVENT_OWNER",
|
||||
PHOTOGRAPHER = "PHOTOGRAPHER",
|
||||
AGENDA_VIEWER = "AGENDA_VIEWER",
|
||||
}
|
||||
|
||||
export enum UserApprovalStatus {
|
||||
|
|
|
|||
Loading…
Reference in a new issue