1153 lines
53 KiB
TypeScript
1153 lines
53 KiB
TypeScript
import React, { useState, useEffect, useMemo } from "react";
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
|
|
import { EventTable } from "../components/EventTable";
|
|
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
|
|
import { EventForm } from "../components/EventForm";
|
|
import { Button } from "../components/Button";
|
|
import {
|
|
PlusCircle,
|
|
Search,
|
|
CheckCircle,
|
|
Clock,
|
|
Edit,
|
|
Users,
|
|
Map,
|
|
Building2,
|
|
Calendar,
|
|
MapPin,
|
|
X,
|
|
UserCheck,
|
|
UserX,
|
|
} from "lucide-react";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { useData } from "../contexts/DataContext";
|
|
import { STATUS_COLORS } from "../constants";
|
|
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
|
|
|
|
interface DashboardProps {
|
|
initialView?: "list" | "create";
|
|
}
|
|
|
|
export const Dashboard: React.FC<DashboardProps> = ({
|
|
initialView = "list",
|
|
}) => {
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
// Extract updateEventDetails from useData
|
|
const {
|
|
events,
|
|
getEventsByRole,
|
|
addEvent,
|
|
updateEventStatus,
|
|
assignPhotographer,
|
|
professionals,
|
|
getInstitutionById,
|
|
getActiveCoursesByInstitutionId,
|
|
respondToAssignment,
|
|
updateEventDetails,
|
|
} = useData();
|
|
|
|
// ... (inside component)
|
|
|
|
const handleSaveEvent = async (data: any) => {
|
|
const isClient = user.role === UserRole.EVENT_OWNER;
|
|
|
|
if (view === "edit" && selectedEvent) {
|
|
if (updateEventDetails) {
|
|
await updateEventDetails(selectedEvent.id, data);
|
|
// Force reload of view to reflect changes (or rely on DataContext optimistic update)
|
|
// But DataContext optimistic update only touched generic fields.
|
|
// Address might still be old in 'selectedEvent' state if we don't update it.
|
|
// Updating selectedEvent manually as well to be safe:
|
|
const updatedEvent = { ...selectedEvent, ...data, date: data.date || data.data_evento?.split('T')[0] || selectedEvent.date };
|
|
setSelectedEvent(updatedEvent);
|
|
setView("details");
|
|
// Optional: Reload page safely if critical fields changed that DataContext map didn't catch?
|
|
// For now, trust DataContext + local state update.
|
|
// Actually, DataContext refetch logic was "try import...", so it might be async.
|
|
// Let's reload window to be 100% sure for the user as requested "mudei a data e não mudou".
|
|
window.location.reload();
|
|
} else {
|
|
console.error("Update function not available");
|
|
}
|
|
} else {
|
|
const initialStatus = isClient
|
|
? EventStatus.PENDING_APPROVAL
|
|
: EventStatus.PLANNING;
|
|
const newEvent: EventData = {
|
|
...data,
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
status: initialStatus,
|
|
checklist: [],
|
|
ownerId: isClient ? user.id : "unknown",
|
|
photographerIds: [],
|
|
};
|
|
addEvent(newEvent);
|
|
setView("list");
|
|
}
|
|
};
|
|
const [view, setView] = useState<"list" | "create" | "edit" | "details">(
|
|
initialView
|
|
);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(null);
|
|
const [activeFilter, setActiveFilter] = useState<string>("all");
|
|
const [advancedFilters, setAdvancedFilters] = useState<EventFilters>({
|
|
date: "",
|
|
fotId: "",
|
|
type: "",
|
|
});
|
|
const [isTeamModalOpen, setIsTeamModalOpen] = useState(false);
|
|
const [viewingProfessional, setViewingProfessional] = useState<Professional | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (initialView) {
|
|
setView(initialView);
|
|
if (initialView === "create") setSelectedEvent(null);
|
|
}
|
|
}, [initialView]);
|
|
|
|
const handleViewProfessional = (professional: Professional) => {
|
|
setViewingProfessional(professional);
|
|
};
|
|
|
|
// Guard Clause for basic security
|
|
if (!user)
|
|
return <div className="p-10 text-center">Acesso Negado. Faça login.</div>;
|
|
|
|
const myEvents = getEventsByRole(user.id, user.role);
|
|
|
|
const currentProfessionalId =
|
|
user.role === UserRole.PHOTOGRAPHER
|
|
? professionals.find((p) => p.usuarioId === user.id)?.id
|
|
: undefined;
|
|
|
|
// Extract unique values for filters
|
|
const { availableTypes } = useMemo(() => {
|
|
const types = [...new Set(myEvents.map((e) => e.type))].sort();
|
|
return {
|
|
availableTypes: types,
|
|
};
|
|
}, [myEvents]);
|
|
|
|
// Filter Logic
|
|
const filteredEvents = myEvents.filter((e) => {
|
|
const matchesSearch = e.name
|
|
.toLowerCase()
|
|
.includes(searchTerm.toLowerCase());
|
|
const matchesStatus =
|
|
activeFilter === "all" ||
|
|
(activeFilter === "pending" &&
|
|
e.status === EventStatus.PENDING_APPROVAL) ||
|
|
(activeFilter === "active" &&
|
|
e.status !== EventStatus.ARCHIVED &&
|
|
e.status !== EventStatus.PENDING_APPROVAL);
|
|
|
|
// Advanced filters
|
|
const matchesDate =
|
|
!advancedFilters.date || e.date === advancedFilters.date;
|
|
const matchesFot =
|
|
!advancedFilters.fotId ||
|
|
String(e.fot || "").toLowerCase().includes(advancedFilters.fotId.toLowerCase());
|
|
const matchesType =
|
|
!advancedFilters.type || e.type === advancedFilters.type;
|
|
|
|
return (
|
|
matchesSearch && matchesStatus && matchesDate && matchesFot && matchesType
|
|
);
|
|
});
|
|
|
|
|
|
|
|
// Keep selectedEvent in sync with global events state
|
|
useEffect(() => {
|
|
if (selectedEvent) {
|
|
const updated = events.find((e) => e.id === selectedEvent.id);
|
|
if (updated && updated !== selectedEvent) {
|
|
setSelectedEvent(updated);
|
|
}
|
|
}
|
|
}, [events, selectedEvent]);
|
|
|
|
const handleApprove = (e: React.MouseEvent, eventId: string) => {
|
|
e.stopPropagation();
|
|
updateEventStatus(eventId, EventStatus.CONFIRMED);
|
|
};
|
|
|
|
const handleAssignmentResponse = async (
|
|
e: React.MouseEvent,
|
|
eventId: string,
|
|
status: string,
|
|
reason?: string
|
|
) => {
|
|
e.stopPropagation();
|
|
await respondToAssignment(eventId, status, reason);
|
|
};
|
|
|
|
const handleOpenMaps = () => {
|
|
if (!selectedEvent) return;
|
|
if (selectedEvent.address.mapLink) {
|
|
window.open(selectedEvent.address.mapLink, "_blank");
|
|
return;
|
|
}
|
|
const { street, number, city, state } = selectedEvent.address;
|
|
const query = encodeURIComponent(
|
|
`${street}, ${number}, ${city} - ${state}`
|
|
);
|
|
window.open(
|
|
`https://www.google.com/maps/search/?api=1&query=${query}`,
|
|
"_blank"
|
|
);
|
|
};
|
|
|
|
const handleManageTeam = () => {
|
|
setIsTeamModalOpen(true);
|
|
};
|
|
|
|
const togglePhotographer = (photographerId: string) => {
|
|
if (!selectedEvent) return;
|
|
assignPhotographer(selectedEvent.id, photographerId);
|
|
};
|
|
|
|
// --- RENDERS PER ROLE ---
|
|
|
|
const renderRoleSpecificHeader = () => {
|
|
if (user.role === UserRole.EVENT_OWNER) {
|
|
return (
|
|
<div>
|
|
<h1 className="text-xl sm:text-2xl md:text-3xl font-serif font-bold text-brand-black">
|
|
Meus Eventos
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-gray-500 mt-0.5 sm:mt-1">
|
|
Acompanhe seus eventos ou solicite novos orçamentos.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
if (user.role === UserRole.PHOTOGRAPHER) {
|
|
return (
|
|
<div>
|
|
<h1 className="text-xl sm:text-2xl md:text-3xl font-serif font-bold text-brand-black">
|
|
Eventos Designados
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-gray-500 mt-0.5 sm:mt-1">
|
|
Gerencie seus trabalhos e visualize detalhes.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<h1 className="text-xl sm:text-2xl md:text-3xl font-serif font-bold text-brand-black">
|
|
Gestão Geral
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-gray-500 mt-0.5 sm:mt-1">
|
|
Controle total de eventos, aprovações e equipes.
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderRoleSpecificActions = () => {
|
|
if (user.role === UserRole.PHOTOGRAPHER) return null;
|
|
|
|
const label =
|
|
user.role === UserRole.EVENT_OWNER
|
|
? "Solicitar Novo Evento"
|
|
: "Novo Evento";
|
|
|
|
return (
|
|
<Button onClick={() => setView("create")} className="shadow-lg">
|
|
<PlusCircle className="mr-2 h-5 w-5" /> {label}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
// --- MAIN RENDER ---
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12 px-3 sm:px-4 lg:px-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
{view === "list" && (
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 sm:mb-8 gap-3 sm:gap-4 fade-in">
|
|
{renderRoleSpecificHeader()}
|
|
{renderRoleSpecificActions()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Switcher */}
|
|
{view === "list" && (
|
|
<div className="space-y-6 fade-in">
|
|
{/* Search Bar */}
|
|
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
|
|
<div className="relative flex-1 w-full">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar evento..."
|
|
className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold text-sm"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{(user.role === UserRole.BUSINESS_OWNER ||
|
|
user.role === UserRole.SUPERADMIN) && (
|
|
<div className="flex space-x-2 bg-white p-1 rounded border border-gray-200">
|
|
<button
|
|
onClick={() => setActiveFilter("all")}
|
|
className={`px-3 py-1 text-xs font-medium rounded-sm ${activeFilter === "all"
|
|
? "bg-brand-black text-white"
|
|
: "text-gray-600 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
Todos
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveFilter("pending")}
|
|
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${activeFilter === "pending"
|
|
? "bg-brand-gold text-white"
|
|
: "text-gray-600 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<Clock size={12} className="mr-1" /> Pendentes
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Advanced Filters */}
|
|
<EventFiltersBar
|
|
filters={advancedFilters}
|
|
onFilterChange={setAdvancedFilters}
|
|
availableTypes={availableTypes}
|
|
/>
|
|
|
|
{/* Results Count */}
|
|
<div className="flex items-center justify-between text-sm text-gray-600">
|
|
<span>
|
|
Exibindo{" "}
|
|
<strong className="text-brand-gold">
|
|
{filteredEvents.length}
|
|
</strong>{" "}
|
|
de <strong>{myEvents.length}</strong> eventos
|
|
</span>
|
|
</div>
|
|
|
|
{/* Event Table */}
|
|
<EventTable
|
|
events={filteredEvents}
|
|
onEventClick={(event) => {
|
|
setSelectedEvent(event);
|
|
setView("details");
|
|
}}
|
|
onApprove={handleApprove}
|
|
userRole={user.role}
|
|
currentProfessionalId={currentProfessionalId}
|
|
onAssignmentResponse={handleAssignmentResponse}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{(view === "create" || view === "edit") && (
|
|
<EventForm
|
|
onCancel={() => setView(view === "edit" ? "details" : "list")}
|
|
onSubmit={handleSaveEvent}
|
|
initialData={view === "edit" ? selectedEvent : undefined}
|
|
/>
|
|
)}
|
|
|
|
{view === "details" && selectedEvent && (
|
|
<div className="fade-in">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setView("list")}
|
|
className="mb-4 pl-0"
|
|
>
|
|
← Voltar para lista
|
|
</Button>
|
|
|
|
{/* Status Banner */}
|
|
{selectedEvent.status === EventStatus.PENDING_APPROVAL &&
|
|
user.role === UserRole.EVENT_OWNER && (
|
|
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-lg mb-6 flex items-start">
|
|
<Clock className="mr-3 flex-shrink-0" />
|
|
<div>
|
|
<h4 className="font-bold">Solicitação em Análise</h4>
|
|
<p className="text-sm mt-1">
|
|
Seu evento foi enviado e está aguardando aprovação da
|
|
equipe Photum.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
|
{/* Header Section - Sem foto */}
|
|
<div className="bg-gradient-to-r from-brand-gold/5 to-brand-black/5 border-b-2 border-brand-gold p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
|
|
{selectedEvent.name}
|
|
</h1>
|
|
<div className="flex flex-wrap gap-3 text-sm text-gray-600">
|
|
<span className="flex items-center gap-1">
|
|
<Calendar size={16} className="text-brand-gold" />
|
|
{new Date(
|
|
selectedEvent.date + "T00:00:00"
|
|
).toLocaleDateString("pt-BR")}{" "}
|
|
às {selectedEvent.time}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<MapPin size={16} className="text-brand-gold" />
|
|
{selectedEvent.address.city},{" "}
|
|
{selectedEvent.address.state}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`px-4 py-2 rounded text-sm font-semibold ${STATUS_COLORS[selectedEvent.status]
|
|
}`}
|
|
>
|
|
{selectedEvent.status}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="flex flex-wrap gap-2 mb-6 pb-4 border-b">
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => navigate(`/agenda/${selectedEvent.id}`)}
|
|
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
|
|
>
|
|
<CheckCircle size={16} className="mr-2" /> Área Operacional
|
|
</Button>
|
|
|
|
{(user.role === UserRole.BUSINESS_OWNER ||
|
|
user.role === UserRole.SUPERADMIN) && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setView("edit")}
|
|
className="text-sm"
|
|
>
|
|
<Edit size={16} className="mr-2" /> Editar Detalhes
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleManageTeam}
|
|
className="text-sm"
|
|
>
|
|
<Users size={16} className="mr-2" /> Gerenciar Equipe
|
|
</Button>
|
|
</>
|
|
)}
|
|
{user.role === UserRole.EVENT_OWNER &&
|
|
selectedEvent.status !== EventStatus.ARCHIVED && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setView("edit")}
|
|
className="text-sm"
|
|
>
|
|
<Edit size={16} className="mr-2" /> Editar Informações
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleOpenMaps}
|
|
className="text-sm"
|
|
>
|
|
<Map size={16} className="mr-2" /> Abrir no Maps
|
|
</Button>
|
|
|
|
<Button
|
|
variant="default"
|
|
onClick={() => {
|
|
// Use a direct window location or navigate if available.
|
|
// Since Dashboard doesn't seem to expose navigate cleanly, likely we need to pass it or use window.location
|
|
// Actually, Dashboard is usually wrapped in a Router context.
|
|
// We can use window.open or window.location.href for now as Dashboard props don't include navigation function easily accessible without hooking.
|
|
// However, line 348 uses setView("list"). Ideally we use useNavigate.
|
|
// Looking at lines 34-45, we don't have navigate.
|
|
// But Dashboard is inside PageWrapper which has navigate? No.
|
|
// Let's use window.location.assign for simplicity or add useNavigate.
|
|
window.location.assign(`/agenda/${selectedEvent.id}`);
|
|
}}
|
|
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
|
|
>
|
|
<CheckCircle size={16} className="mr-2" /> Área Operacional
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Quick Info Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div className="bg-gray-50 p-4 rounded border border-gray-200">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
|
|
Tipo
|
|
</p>
|
|
<p className="font-semibold text-gray-900">
|
|
{selectedEvent.type}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 p-4 rounded border border-gray-200">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
|
|
Data
|
|
</p>
|
|
<p className="font-semibold text-gray-900">
|
|
{new Date(
|
|
selectedEvent.date + "T00:00:00"
|
|
).toLocaleDateString("pt-BR")}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 p-4 rounded border border-gray-200">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
|
|
Horário
|
|
</p>
|
|
<p className="font-semibold text-gray-900">
|
|
{selectedEvent.time}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* FOT Information Table */}
|
|
<section className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
<div className="bg-gradient-to-r from-brand-purple to-brand-purple/90 px-4 py-3">
|
|
<h3 className="text-base font-bold text-white">
|
|
Informações FOT
|
|
</h3>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<tbody className="divide-y divide-gray-200">
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50 w-1/3">
|
|
FOT
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900 font-medium">
|
|
{(selectedEvent as any).fot || "-"}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Data
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{new Date(
|
|
selectedEvent.date + "T00:00:00"
|
|
).toLocaleDateString("pt-BR")}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Curso
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.curso || "-"}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Instituição
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.instituicao || "-"}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Ano Formatura
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.anoFormatura || "-"}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Empresa
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.empresa || "-"}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Tipo Evento
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.type}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Observações
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{(selectedEvent as any).observacoes || "-"}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Local
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{(selectedEvent as any).local_evento || (selectedEvent as any).locationName || "-"}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Endereço
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.address.street}, {selectedEvent.address.number} - {selectedEvent.address.city}/{selectedEvent.address.state}
|
|
{selectedEvent.address.zip && ` | CEP: ${selectedEvent.address.zip}`}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Horário
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.time}
|
|
</td>
|
|
</tr>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
|
|
Qtd Formandos
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-900">
|
|
{selectedEvent.attendees || "-"}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Institution Information */}
|
|
{selectedEvent.institutionId &&
|
|
(() => {
|
|
const institution = getInstitutionById(
|
|
selectedEvent.institutionId
|
|
);
|
|
if (institution) {
|
|
return (
|
|
<section className="bg-gradient-to-br from-brand-gold/10 to-transparent border border-brand-gold/30 rounded-sm p-6">
|
|
<div className="flex items-start space-x-4">
|
|
<div className="bg-brand-gold/20 p-3 rounded-full">
|
|
<Building2
|
|
className="text-brand-gold"
|
|
size={24}
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-bold text-brand-black mb-1">
|
|
{institution.name}
|
|
</h3>
|
|
<p className="text-sm text-brand-gold uppercase tracking-wide font-medium mb-3">
|
|
{institution.type}
|
|
</p>
|
|
|
|
{/* Course Information */}
|
|
{selectedEvent.courseId &&
|
|
(() => {
|
|
const course =
|
|
getActiveCoursesByInstitutionId(
|
|
selectedEvent.institutionId
|
|
).find(
|
|
(c) => c.id === selectedEvent.courseId
|
|
);
|
|
if (course) {
|
|
return (
|
|
<div className="bg-brand-gold/10 border border-brand-gold/30 rounded px-3 py-2 mb-3">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-0.5">
|
|
Curso/Turma
|
|
</p>
|
|
<p className="text-sm font-semibold text-brand-black">
|
|
{course.name} -{" "}
|
|
{course.graduationType} (
|
|
{course.year}/{course.semester}º
|
|
sem)
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<p className="text-gray-500 text-xs uppercase tracking-wide">
|
|
Contato
|
|
</p>
|
|
<p className="text-gray-700 font-medium">
|
|
{institution.phone}
|
|
</p>
|
|
<p className="text-gray-600">
|
|
{institution.email}
|
|
</p>
|
|
</div>
|
|
|
|
{institution.address && (
|
|
<div>
|
|
<p className="text-gray-500 text-xs uppercase tracking-wide">
|
|
Endereço
|
|
</p>
|
|
<p className="text-gray-700">
|
|
{institution.address.street},{" "}
|
|
{institution.address.number}
|
|
</p>
|
|
<p className="text-gray-600">
|
|
{institution.address.city} -{" "}
|
|
{institution.address.state}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{institution.description && (
|
|
<p className="text-gray-600 text-sm mt-3 italic border-t border-brand-gold/20 pt-3">
|
|
{institution.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
<section>
|
|
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
|
|
Sobre o Evento
|
|
</h3>
|
|
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">
|
|
{selectedEvent.briefing || "Sem briefing detalhado."}
|
|
</p>
|
|
</section>
|
|
|
|
{selectedEvent.contacts.length > 0 && (
|
|
<section>
|
|
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
|
|
Contatos & Responsáveis
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{selectedEvent.contacts.map((c, i) => (
|
|
<div
|
|
key={i}
|
|
className="bg-gray-50 p-4 rounded-sm border border-gray-100"
|
|
>
|
|
<p className="font-bold text-sm">{c.name}</p>
|
|
<p className="text-xs text-brand-gold uppercase tracking-wide">
|
|
{c.role}
|
|
</p>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
{c.phone}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
|
|
<div className="lg:col-span-1 space-y-4">
|
|
{/* Localização Card */}
|
|
<div className="border p-5 rounded bg-gray-50">
|
|
<h4 className="font-bold text-sm mb-3 text-gray-700 flex items-center gap-2">
|
|
<MapPin size={16} className="text-brand-gold" />
|
|
Localização
|
|
</h4>
|
|
<p className="font-medium text-base mb-1">
|
|
{selectedEvent.address.street},{" "}
|
|
{selectedEvent.address.number}
|
|
</p>
|
|
<p className="text-gray-600 text-sm">
|
|
{selectedEvent.address.city} -{" "}
|
|
{selectedEvent.address.state}
|
|
</p>
|
|
{selectedEvent.address.zip && (
|
|
<p className="text-gray-500 text-xs mt-1">
|
|
CEP: {selectedEvent.address.zip}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Equipe Designada */}
|
|
{(selectedEvent.assignments && selectedEvent.assignments.filter(a => a.status !== "REJEITADO").length > 0) ||
|
|
((user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && selectedEvent.photographerIds.length > 0) ? (
|
|
<div className="border p-5 rounded bg-white">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h4 className="font-bold text-sm text-gray-700 flex items-center gap-2">
|
|
<Users size={16} className="text-brand-gold" />
|
|
Equipe ({(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length})
|
|
</h4>
|
|
{(user.role === UserRole.BUSINESS_OWNER ||
|
|
user.role === UserRole.SUPERADMIN) && (
|
|
<button
|
|
onClick={handleManageTeam}
|
|
className="text-brand-gold hover:text-brand-black transition-colors"
|
|
title="Adicionar fotógrafo"
|
|
>
|
|
<PlusCircle size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{(selectedEvent.assignments || [])
|
|
.filter(a => a.status !== "REJEITADO")
|
|
.map((assignment) => {
|
|
const photographer = professionals.find(
|
|
(p) => p.id === assignment.professionalId
|
|
);
|
|
return (
|
|
<div
|
|
key={assignment.professionalId}
|
|
className="flex items-center justify-between gap-2 text-sm mb-2"
|
|
>
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={() => handleViewProfessional(photographer!)}
|
|
>
|
|
<div
|
|
className="w-8 h-8 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
|
|
style={{
|
|
backgroundImage: `url(${photographer?.avatar ||
|
|
`https://i.pravatar.cc/100?u=${assignment.professionalId}`
|
|
})`,
|
|
backgroundSize: "cover",
|
|
}}
|
|
></div>
|
|
<div className="flex flex-col">
|
|
<span className="text-gray-700 font-medium">
|
|
{photographer?.name || "Fotógrafo"}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{assignment.status === "PENDENTE" && (
|
|
<span className="w-2 h-2 rounded-full bg-yellow-400" title="Pendente"></span>
|
|
)}
|
|
{assignment.status === "ACEITO" && (
|
|
<span className="w-2 h-2 rounded-full bg-green-500" title="Aceito"></span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length === 0 && (
|
|
<p className="text-sm text-gray-400 italic">
|
|
Nenhum profissional na equipe.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Gerenciamento de Equipe */}
|
|
{isTeamModalOpen && selectedEvent && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-[#492E61] to-[#5a3a7a] p-6 flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-white mb-1">
|
|
Gerenciar Equipe
|
|
</h2>
|
|
<p className="text-white/80 text-sm">
|
|
{selectedEvent.name} -{" "}
|
|
{new Date(
|
|
selectedEvent.date + "T00:00:00"
|
|
).toLocaleDateString("pt-BR")}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsTeamModalOpen(false)}
|
|
className="text-white hover:bg-white/20 rounded-full p-2 transition-colors"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-auto p-6">
|
|
<div className="mb-4">
|
|
<p className="text-sm text-gray-600">
|
|
Profissionais disponíveis para a data{" "}
|
|
<strong>
|
|
{new Date(
|
|
selectedEvent.date + "T00:00:00"
|
|
).toLocaleDateString("pt-BR")}
|
|
</strong>
|
|
. Clique em "Adicionar" para atribuir ao evento.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tabela de Profissionais (Desktop) */}
|
|
<div className="hidden md:block overflow-x-auto">
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-b-2 border-gray-200">
|
|
<th className="text-left p-4 font-semibold text-gray-700">
|
|
Profissional
|
|
</th>
|
|
<th className="text-center p-4 font-semibold text-gray-700">
|
|
Função
|
|
</th>
|
|
<th className="text-center p-4 font-semibold text-gray-700">
|
|
E-mail
|
|
</th>
|
|
<th className="text-center p-4 font-semibold text-gray-700">
|
|
Status
|
|
</th>
|
|
<th className="text-center p-4 font-semibold text-gray-700">
|
|
Ação
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{professionals.map((photographer) => {
|
|
const assignment = (selectedEvent.assignments || []).find(
|
|
(a) => a.professionalId === photographer.id
|
|
);
|
|
|
|
const status = assignment ? assignment.status : null;
|
|
const isAssigned = !!status && status !== "REJEITADO";
|
|
|
|
// Check if busy in other events on the same date
|
|
const isBusy = !isAssigned && events.some(e =>
|
|
e.id !== selectedEvent.id &&
|
|
e.date === selectedEvent.date &&
|
|
e.photographerIds.includes(photographer.id)
|
|
);
|
|
|
|
return (
|
|
<tr
|
|
key={photographer.id}
|
|
className="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
|
>
|
|
{/* Profissional */}
|
|
<td className="p-4 cursor-pointer" onClick={() => handleViewProfessional(photographer)}>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="w-10 h-10 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
|
|
style={{
|
|
backgroundImage: `url(${photographer.avatar})`,
|
|
backgroundSize: "cover",
|
|
}}
|
|
/>
|
|
<div>
|
|
<p className="font-semibold text-gray-900">
|
|
{photographer.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
ID: {photographer.id}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Função */}
|
|
<td className="p-4 text-center">
|
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
|
{photographer.role}
|
|
</span>
|
|
</td>
|
|
|
|
{/* E-mail */}
|
|
<td className="p-4 text-center text-sm text-gray-600">
|
|
{photographer.email}
|
|
</td>
|
|
|
|
{/* Status */}
|
|
<td className="p-4 text-center">
|
|
{status === "ACEITO" && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium">
|
|
<CheckCircle size={14} />
|
|
Confirmado
|
|
</span>
|
|
)}
|
|
{status === "PENDENTE" && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
|
|
<Clock size={14} />
|
|
Pendente
|
|
</span>
|
|
)}
|
|
{status === "REJEITADO" && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium">
|
|
<X size={14} />
|
|
Recusado
|
|
</span>
|
|
)}
|
|
{!status && (
|
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
|
|
{isBusy ? <UserX size={14} /> : <UserCheck size={14} />}
|
|
{isBusy ? "Em outro evento" : "Disponível"}
|
|
</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* Ação */}
|
|
<td className="p-4 text-center">
|
|
<button
|
|
onClick={() =>
|
|
togglePhotographer(photographer.id)
|
|
}
|
|
disabled={!status && isBusy}
|
|
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${status === "ACEITO" || status === "PENDENTE"
|
|
? "bg-red-100 text-red-700 hover:bg-red-200"
|
|
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
|
|
}`}
|
|
>
|
|
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : "Adicionar"}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{professionals.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="p-8 text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<UserX size={48} className="text-gray-300" />
|
|
<p className="text-gray-500 font-medium">
|
|
Nenhum profissional disponível para esta data
|
|
</p>
|
|
<p className="text-sm text-gray-400">
|
|
Tente selecionar outra data ou entre em contato
|
|
com a equipe
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Lista de Cards (Mobile) */}
|
|
<div className="md:hidden space-y-4">
|
|
{professionals.map((photographer) => {
|
|
const assignment = (selectedEvent.assignments || []).find(
|
|
(a) => a.professionalId === photographer.id
|
|
);
|
|
const status = assignment ? assignment.status : null;
|
|
|
|
// Check if busy in other events on the same date
|
|
const isBusy = !status && events.some(e =>
|
|
e.id !== selectedEvent.id &&
|
|
e.date === selectedEvent.date &&
|
|
e.photographerIds.includes(photographer.id)
|
|
);
|
|
|
|
return (
|
|
<div key={photographer.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-center gap-3 mb-3" onClick={() => handleViewProfessional(photographer)}>
|
|
<div
|
|
className="w-12 h-12 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
|
|
style={{
|
|
backgroundImage: `url(${photographer.avatar})`,
|
|
backgroundSize: "cover",
|
|
}}
|
|
/>
|
|
<div>
|
|
<h4 className="font-bold text-gray-900">{photographer.name || photographer.nome}</h4>
|
|
<p className="text-xs text-gray-500">ID: {photographer.id.substring(0, 8)}...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
|
{photographer.role}
|
|
</span>
|
|
|
|
{status === "ACEITO" && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium">
|
|
<CheckCircle size={12} /> Confirmado
|
|
</span>
|
|
)}
|
|
{status === "PENDENTE" && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
|
|
<Clock size={12} /> Pendente
|
|
</span>
|
|
)}
|
|
{status === "REJEITADO" && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium">
|
|
<X size={12} /> Recusado
|
|
</span>
|
|
)}
|
|
{!status && (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
|
|
{isBusy ? <UserX size={12} /> : <UserCheck size={12} />}
|
|
{isBusy ? "Em outro evento" : "Disponível"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleViewProfessional(photographer)}
|
|
className="flex-1 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 bg-white"
|
|
>
|
|
Ver Detalhes
|
|
</button>
|
|
<button
|
|
onClick={() => togglePhotographer(photographer.id)}
|
|
disabled={!status && isBusy}
|
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${status === "ACEITO" || status === "PENDENTE"
|
|
? "bg-red-100 text-red-700 hover:bg-red-200"
|
|
: isBusy ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-brand-gold text-white hover:bg-[#a5bd2e]"
|
|
}`}
|
|
>
|
|
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : isBusy ? "Ocupado" : "Adicionar"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{professionals.length === 0 && (
|
|
<div className="text-center p-8 text-gray-500">
|
|
<UserX size={48} className="mx-auto text-gray-300 mb-2" />
|
|
<p>Nenhum profissional disponível.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="border-t border-gray-200 p-6 bg-gray-50 flex justify-end gap-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsTeamModalOpen(false)}
|
|
>
|
|
Fechar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{viewingProfessional && (
|
|
<ProfessionalDetailsModal
|
|
professional={viewingProfessional}
|
|
isOpen={!!viewingProfessional}
|
|
onClose={() => setViewingProfessional(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|