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.PUT("/cadastro-fot/:id", cadastroFotHandler.Update)
|
||||||
api.DELETE("/cadastro-fot/:id", cadastroFotHandler.Delete)
|
api.DELETE("/cadastro-fot/:id", cadastroFotHandler.Delete)
|
||||||
|
|
||||||
|
// Agenda routes - read access for AGENDA_VIEWER
|
||||||
api.GET("/agenda", agendaHandler.List)
|
api.GET("/agenda", agendaHandler.List)
|
||||||
api.POST("/agenda", agendaHandler.Create)
|
|
||||||
api.GET("/agenda/:id", agendaHandler.Get)
|
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.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.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.POST("/availability", availabilityHandler.SetAvailability)
|
||||||
api.GET("/availability", availabilityHandler.ListAvailability)
|
api.GET("/availability", availabilityHandler.ListAvailability)
|
||||||
|
|
@ -228,8 +231,8 @@ func main() {
|
||||||
api.DELETE("/escalas/:id", escalasHandler.Delete)
|
api.DELETE("/escalas/:id", escalasHandler.Delete)
|
||||||
api.PUT("/escalas/:id", escalasHandler.Update)
|
api.PUT("/escalas/:id", escalasHandler.Update)
|
||||||
|
|
||||||
// Logistics Routes
|
// Logistics Routes - blocked for AGENDA_VIEWER
|
||||||
logisticaGroup := api.Group("/logistica")
|
logisticaGroup := api.Group("/logistica", auth.RequireLogisticsAccess())
|
||||||
{
|
{
|
||||||
logisticaGroup.POST("/carros", logisticaHandler.CreateCarro)
|
logisticaGroup.POST("/carros", logisticaHandler.CreateCarro)
|
||||||
logisticaGroup.GET("/carros", logisticaHandler.ListCarros)
|
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()
|
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"
|
RoleBusinessOwner = "BUSINESS_OWNER"
|
||||||
RolePhotographer = "PHOTOGRAPHER"
|
RolePhotographer = "PHOTOGRAPHER"
|
||||||
RoleEventOwner = "EVENT_OWNER"
|
RoleEventOwner = "EVENT_OWNER"
|
||||||
|
RoleAgendaViewer = "AGENDA_VIEWER"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,19 @@ interface Carro {
|
||||||
passengers: any[]; // We will fetch and attach
|
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 EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfessionals }) => {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const { professionals } = useData();
|
const { professionals } = useData();
|
||||||
const [carros, setCarros] = useState<Carro[]>([]);
|
const [carros, setCarros] = useState<Carro[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [passengerOrders, setPassengerOrders] = useState<Record<string, Record<string, number>>>({});
|
||||||
|
|
||||||
// New Car State
|
// New Car State
|
||||||
const [driverId, setDriverId] = useState("");
|
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;
|
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(() => {
|
useEffect(() => {
|
||||||
if (agendaId && token) {
|
if (agendaId && token) {
|
||||||
loadCarros();
|
loadCarros();
|
||||||
|
|
@ -49,6 +76,20 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
|
||||||
return { ...car, passengers: passRes.data || [] };
|
return { ...car, passengers: passRes.data || [] };
|
||||||
}));
|
}));
|
||||||
setCarros(carsWithPassengers);
|
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);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
@ -82,6 +123,19 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
|
||||||
if (!profId) return;
|
if (!profId) return;
|
||||||
const res = await addPassenger(carId, profId, token!);
|
const res = await addPassenger(carId, profId, token!);
|
||||||
if (!res.error) {
|
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();
|
loadCarros();
|
||||||
} else {
|
} else {
|
||||||
alert("Erro ao adicionar passageiro: " + res.error);
|
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 handleRemovePassenger = async (carId: string, profId: string) => {
|
||||||
const res = await removePassenger(carId, profId, token!);
|
const res = await removePassenger(carId, profId, token!);
|
||||||
if (!res.error) {
|
if (!res.error) {
|
||||||
|
// Remover ordem do passageiro
|
||||||
|
setPassengerOrders(prev => {
|
||||||
|
const newOrders = { ...prev };
|
||||||
|
if (newOrders[carId]) {
|
||||||
|
delete newOrders[carId][profId];
|
||||||
|
}
|
||||||
|
return newOrders;
|
||||||
|
});
|
||||||
loadCarros();
|
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 (
|
return (
|
||||||
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center">
|
<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">
|
<div className="space-y-1 mb-3">
|
||||||
<p className="text-xs font-semibold text-gray-500 uppercase">Passageiros</p>
|
<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.length === 0 && <p className="text-xs italic text-gray-400">Vazio</p>}
|
||||||
{car.passengers.map((p: any) => (
|
{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 rounded px-2 border">
|
<div key={p.id} className="flex justify-between items-center text-sm bg-white p-1.5 rounded px-2 border">
|
||||||
<span className="truncate">{p.name || "Desconhecido"}</span>
|
{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 && (
|
{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} />
|
<Trash size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
|
||||||
const [role, setRole] = useState("");
|
const [role, setRole] = useState("");
|
||||||
|
|
||||||
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
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
|
// Helper to check availability
|
||||||
const checkAvailability = (profId: string) => {
|
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: "Códigos de Acesso", path: "codigos-acesso" },
|
||||||
{ name: "Financeiro", path: "financeiro" },
|
{ name: "Financeiro", path: "financeiro" },
|
||||||
];
|
];
|
||||||
|
case UserRole.AGENDA_VIEWER:
|
||||||
|
return [
|
||||||
|
{ name: "Agenda", path: "painel" },
|
||||||
|
];
|
||||||
case UserRole.EVENT_OWNER:
|
case UserRole.EVENT_OWNER:
|
||||||
return [
|
return [
|
||||||
{ name: "Meus Eventos", path: "painel" },
|
{ 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.EVENT_OWNER) return "Cliente";
|
||||||
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
|
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
|
||||||
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
|
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
|
||||||
|
if (user.role === UserRole.AGENDA_VIEWER) return "Visualizador";
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,12 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validação de foto de perfil
|
||||||
|
if (!formData.avatar) {
|
||||||
|
alert("A foto de perfil é obrigatória!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validação de senha
|
// Validação de senha
|
||||||
if (formData.senha !== formData.confirmarSenha) {
|
if (formData.senha !== formData.confirmarSenha) {
|
||||||
alert("As senhas não coincidem!");
|
alert("As senhas não coincidem!");
|
||||||
|
|
@ -243,7 +249,7 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Foto de Perfil
|
Foto de Perfil *
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{avatarPreview ? (
|
{avatarPreview ? (
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ const MOCK_USERS: User[] = [
|
||||||
email: 'cliente@photum.com',
|
email: 'cliente@photum.com',
|
||||||
role: UserRole.EVENT_OWNER,
|
role: UserRole.EVENT_OWNER,
|
||||||
avatar: 'https://i.pravatar.cc/150?u=client'
|
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
|
// Filtro por função/role
|
||||||
if (teamRoleFilter !== "all") {
|
if (teamRoleFilter !== "all") {
|
||||||
|
const professionalFunctions = professional.functions || [];
|
||||||
const professionalRole = (professional.role || "").toLowerCase();
|
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
|
// Verificar status do assignment para este evento
|
||||||
|
|
@ -525,7 +546,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRoleSpecificActions = () => {
|
const renderRoleSpecificActions = () => {
|
||||||
if (user.role === UserRole.PHOTOGRAPHER) return null;
|
if (user.role === UserRole.PHOTOGRAPHER || user.role === UserRole.AGENDA_VIEWER) return null;
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
user.role === UserRole.EVENT_OWNER
|
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="all">Todas as funções</option>
|
||||||
<option value="fot">Fotógrafos</option>
|
<option value="fot">Fotógrafos</option>
|
||||||
<option value="video">Videomakers</option>
|
<option value="video">Cinegrafistas</option>
|
||||||
<option value="editor">Editores</option>
|
<option value="editor">Recepcionistas</option>
|
||||||
<option value="assist">Assistentes</option>
|
<option value="assist">Apoio</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Filtro por status */}
|
{/* Filtro por status */}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import { ArrowLeft, MapPin, Calendar, Clock, DollarSign } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useData } from '../contexts/DataContext';
|
import { useData } from '../contexts/DataContext';
|
||||||
import { getAgendas } from '../services/apiService';
|
import { getAgendas } from '../services/apiService';
|
||||||
|
import { UserRole } from '../types';
|
||||||
import EventScheduler from '../components/EventScheduler';
|
import EventScheduler from '../components/EventScheduler';
|
||||||
import EventLogistics from '../components/EventLogistics';
|
import EventLogistics from '../components/EventLogistics';
|
||||||
|
|
||||||
const EventDetails: React.FC = () => {
|
const EventDetails: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { events, loading } = useData();
|
const { events, loading } = useData();
|
||||||
const [calculatedStats, setCalculatedStats] = useState({ studios: 0 });
|
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>;
|
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
|
// Use event.date which is already YYYY-MM-DD from DataContext
|
||||||
const formattedDate = new Date(event.date + "T00:00:00").toLocaleDateString();
|
const formattedDate = new Date(event.date + "T00:00:00").toLocaleDateString();
|
||||||
|
|
||||||
|
|
@ -96,7 +101,7 @@ const EventDetails: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content: Scheduling & Logistics */}
|
{/* 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
|
<EventScheduler
|
||||||
agendaId={id!}
|
agendaId={id!}
|
||||||
dataEvento={event.date}
|
dataEvento={event.date}
|
||||||
|
|
@ -105,7 +110,8 @@ const EventDetails: React.FC = () => {
|
||||||
defaultTime={event.time}
|
defaultTime={event.time}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right: Logistics (Carros) */}
|
{/* Right: Logistics (Carros) - Only visible if user has permission */}
|
||||||
|
{canViewLogistics && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<EventLogistics
|
<EventLogistics
|
||||||
agendaId={id!}
|
agendaId={id!}
|
||||||
|
|
@ -138,6 +144,7 @@ const EventDetails: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,14 @@ const Finance: React.FC = () => {
|
||||||
status: "",
|
status: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Advanced date filters
|
||||||
|
const [dateFilters, setDateFilters] = useState({
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
includeWeekends: true,
|
||||||
|
});
|
||||||
|
const [showDateFilters, setShowDateFilters] = useState(false);
|
||||||
|
|
||||||
// Calculate filtered and sorted transactions
|
// Calculate filtered and sorted transactions
|
||||||
const sortedTransactions = React.useMemo(() => {
|
const sortedTransactions = React.useMemo(() => {
|
||||||
let result = [...transactions];
|
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);
|
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
|
// 2. Sort by FOT (desc) then Date (desc) to group FOTs
|
||||||
// Default sort is grouped by FOT
|
// Default sort is grouped by FOT
|
||||||
if (!sortConfig) {
|
if (!sortConfig) {
|
||||||
|
|
@ -218,7 +264,7 @@ const Finance: React.FC = () => {
|
||||||
if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1;
|
if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [transactions, filters, sortConfig]);
|
}, [transactions, filters, sortConfig, dateFilters]);
|
||||||
|
|
||||||
|
|
||||||
const handleSort = (key: keyof FinancialTransaction) => {
|
const handleSort = (key: keyof FinancialTransaction) => {
|
||||||
|
|
@ -589,6 +635,63 @@ const Finance: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* List */}
|
||||||
<div className="bg-white rounded shadow overflow-x-auto">
|
<div className="bg-white rounded shadow overflow-x-auto">
|
||||||
<table className="w-full text-xs text-left whitespace-nowrap">
|
<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 [accessCode, setAccessCode] = useState("");
|
||||||
const [showProfessionalPrompt, setShowProfessionalPrompt] = useState(false);
|
const [showProfessionalPrompt, setShowProfessionalPrompt] = useState(false);
|
||||||
const [codeError, setCodeError] = useState("");
|
const [codeError, setCodeError] = useState("");
|
||||||
|
const [isProfessionalRegistration, setIsProfessionalRegistration] = useState(false);
|
||||||
|
|
||||||
const handleRegisterClick = () => {
|
const handleRegisterClick = () => {
|
||||||
setShowProfessionalPrompt(true);
|
setShowProfessionalPrompt(true);
|
||||||
|
|
@ -29,7 +30,11 @@ export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
||||||
const res = await verifyAccessCode(accessCode.toUpperCase());
|
const res = await verifyAccessCode(accessCode.toUpperCase());
|
||||||
if (res.data && res.data.valid) {
|
if (res.data && res.data.valid) {
|
||||||
setShowAccessCodeModal(false);
|
setShowAccessCodeModal(false);
|
||||||
|
if (isProfessionalRegistration) {
|
||||||
|
window.location.href = "/cadastro-profissional";
|
||||||
|
} else {
|
||||||
window.location.href = "/cadastro";
|
window.location.href = "/cadastro";
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCodeError(res.data?.error || "Código de acesso inválido ou expirado");
|
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) => {
|
const handleProfessionalChoice = (isProfessional: boolean) => {
|
||||||
setShowProfessionalPrompt(false);
|
setShowProfessionalPrompt(false);
|
||||||
if (isProfessional) {
|
setIsProfessionalRegistration(isProfessional);
|
||||||
window.location.href = "/cadastro-profissional";
|
|
||||||
} else {
|
|
||||||
setShowAccessCodeModal(true);
|
setShowAccessCodeModal(true);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ProfessionalForm,
|
ProfessionalForm,
|
||||||
ProfessionalData,
|
ProfessionalData,
|
||||||
} from "../components/ProfessionalForm";
|
} from "../components/ProfessionalForm";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { verifyAccessCode } from "../services/apiService";
|
||||||
|
|
||||||
interface ProfessionalRegisterProps {
|
interface ProfessionalRegisterProps {
|
||||||
onNavigate: (page: string) => void;
|
onNavigate: (page: string) => void;
|
||||||
|
|
@ -14,6 +16,40 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
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) => {
|
const handleSubmit = async (professionalData: ProfessionalData) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -35,8 +71,11 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
||||||
const { createProfessional, getUploadURL, uploadFileToSignedUrl } = await import("../services/apiService");
|
const { createProfessional, getUploadURL, uploadFileToSignedUrl } = await import("../services/apiService");
|
||||||
|
|
||||||
let avatarUrl = "";
|
let avatarUrl = "";
|
||||||
// Upload de Avatar (se existir)
|
// Upload de Avatar (obrigatório)
|
||||||
if (professionalData.avatar) {
|
if (!professionalData.avatar) {
|
||||||
|
throw new Error("A foto de perfil é obrigatória.");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Iniciando upload do avatar...");
|
console.log("Iniciando upload do avatar...");
|
||||||
const uploadRes = await getUploadURL(professionalData.avatar.name, professionalData.avatar.type);
|
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);
|
console.log("Upload concluído. URL:", avatarUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erro no upload do avatar:", 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"));
|
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
|
// Mapear dados do formulário para o payload esperado pelo backend
|
||||||
|
|
||||||
|
|
@ -159,10 +194,107 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<ProfessionalForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onNavigate("cadastro")}
|
onCancel={() => onNavigate("cadastro")}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export enum UserRole {
|
||||||
BUSINESS_OWNER = "BUSINESS_OWNER",
|
BUSINESS_OWNER = "BUSINESS_OWNER",
|
||||||
EVENT_OWNER = "EVENT_OWNER",
|
EVENT_OWNER = "EVENT_OWNER",
|
||||||
PHOTOGRAPHER = "PHOTOGRAPHER",
|
PHOTOGRAPHER = "PHOTOGRAPHER",
|
||||||
|
AGENDA_VIEWER = "AGENDA_VIEWER",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum UserApprovalStatus {
|
export enum UserApprovalStatus {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue