2482 lines
123 KiB
JavaScript
2482 lines
123 KiB
JavaScript
import React, { useState, useEffect } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { createPageUrl } from "@/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Users, Plus, Search, Building2, MapPin, UserCheck, Mail, Edit, Loader2, Trash2, UserX, LayoutGrid, List as ListIcon, RefreshCw, Send, Filter, Star, UserPlus } from "lucide-react";
|
|
import PageHeader from "../components/common/PageHeader";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
export default function Teams() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [departmentFilter, setDepartmentFilter] = useState("all");
|
|
const [viewMode, setViewMode] = useState("grid");
|
|
const [activeTab, setActiveTab] = useState("active");
|
|
const [showInviteMemberDialog, setShowInviteMemberDialog] = useState(false);
|
|
const [showEditMemberDialog, setShowEditMemberDialog] = useState(false);
|
|
const [showDepartmentDialog, setShowDepartmentDialog] = useState(false);
|
|
const [showDeleteTeamDialog, setShowDeleteTeamDialog] = useState(false);
|
|
const [editingMember, setEditingMember] = useState(null);
|
|
const [editingDepartment, setEditingDepartment] = useState(null);
|
|
const [teamToDelete, setTeamToDelete] = useState(null);
|
|
const [newDepartment, setNewDepartment] = useState("");
|
|
const [favoriteSearch, setFavoriteSearch] = useState("");
|
|
const [blockedSearch, setBlockedSearch] = useState("");
|
|
const [showAddFavoriteDialog, setShowAddFavoriteDialog] = useState(false);
|
|
const [showAddBlockedDialog, setShowAddBlockedDialog] = useState(false);
|
|
const [blockReason, setBlockReason] = useState("");
|
|
const [showAddHubDialog, setShowAddHubDialog] = useState(false);
|
|
const [showAddHubDepartmentDialog, setShowAddHubDepartmentDialog] = useState(false);
|
|
const [selectedHubForDept, setSelectedHubForDept] = useState(null);
|
|
const [preSelectedHub, setPreSelectedHub] = useState(null);
|
|
const [newHubDepartment, setNewHubDepartment] = useState({
|
|
department_name: "",
|
|
cost_center: ""
|
|
});
|
|
|
|
const [inviteData, setInviteData] = useState({
|
|
email: "",
|
|
full_name: "",
|
|
role: "member",
|
|
hub: "",
|
|
department: "",
|
|
});
|
|
|
|
const [newHub, setNewHub] = useState({
|
|
hub_name: "",
|
|
address: "",
|
|
manager_name: "",
|
|
manager_position: "",
|
|
manager_email: ""
|
|
});
|
|
|
|
const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
|
|
const addressInputRef = React.useRef(null);
|
|
const autocompleteRef = React.useRef(null);
|
|
|
|
const { data: user } = useQuery({
|
|
queryKey: ['current-user-teams'],
|
|
queryFn: () => base44.auth.me(),
|
|
});
|
|
|
|
const userRole = user?.user_role || user?.role;
|
|
|
|
/**
|
|
* CRITICAL ISOLATION LAYER:
|
|
* Each role (Admin, Procurement, Operator, Sector, Client, Vendor, Workforce)
|
|
* gets their OWN isolated team that ONLY they can see and manage.
|
|
*
|
|
* Security Rules:
|
|
* - Teams are filtered by owner_id === current user's ID
|
|
* - Vendors CANNOT see Procurement teams
|
|
* - Procurement CANNOT see Vendor teams
|
|
* - Operators CANNOT see Client teams
|
|
* - NO cross-layer visibility is allowed
|
|
*
|
|
* This ensures complete data isolation across all organizational layers.
|
|
*/
|
|
const { data: userTeam } = useQuery({
|
|
queryKey: ['user-team', user?.id, userRole],
|
|
queryFn: async () => {debugger;
|
|
if (!user?.id) {
|
|
console.warn("⚠️ No user ID found - cannot fetch team");
|
|
return null;
|
|
}
|
|
|
|
// SECURITY: Fetch ALL teams and filter by owner_id
|
|
// This ensures only THIS user's team is returned
|
|
|
|
const allTeams = await base44.entities.Team.list('-created_date');
|
|
|
|
// Find ONLY teams owned by this specific user
|
|
let team = allTeams.find(t => t.ownerId === user.id);
|
|
console.log( team);
|
|
// ISOLATION VERIFICATION
|
|
if (team && team.ownerId !== user.id) {
|
|
console.error("🚨 SECURITY VIOLATION: Team owner mismatch!");
|
|
return null;
|
|
}
|
|
// Auto-create team if doesn't exist (first time user accesses Teams)
|
|
if (!team && user.id) {
|
|
console.log(`✅ Creating new isolated team for ${userRole} user: ${user.email}`);
|
|
const teamName = user.companyName || `${user.fullName}'s Team` || "My Team";
|
|
try {
|
|
team = await base44.entities.Team.create({
|
|
teamName: teamName,
|
|
ownerId: user.id, // CRITICAL: Links team to THIS user only
|
|
ownerName: user.fullName || user.email,
|
|
ownerRole: userRole, // Tracks which layer this team belongs to
|
|
//email: user.email,
|
|
//phone: user.phone || "",
|
|
//total_members: 0,
|
|
//active_members: 0,
|
|
//total_hubs: 0,
|
|
favoriteStaff: 0,
|
|
blockedStaff: 0,
|
|
//departments: [], // Initialize with an empty array for departments
|
|
});
|
|
|
|
console.log(`✅ Team created successfully for ${userRole}: ${team.id}`);
|
|
} catch (err) {
|
|
console.error("🔥 EXCEPTION in Team.list:", err);
|
|
}
|
|
}
|
|
|
|
// FINAL VERIFICATION: Ensure team belongs to current user
|
|
if (team) {
|
|
console.log(`🔒 Team loaded for ${userRole}: ${team.teamName} (Owner: ${team.ownerId})`);
|
|
|
|
// Double-check ownership
|
|
if (team.ownerId !== user.id) {
|
|
console.error("🚨 CRITICAL: Attempted to load team not owned by current user!");
|
|
toast({
|
|
title: "⛔ Access Denied",
|
|
description: "You don't have permission to view this team.",
|
|
variant: "destructive",
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return team;
|
|
},
|
|
enabled: !!user?.id,
|
|
});
|
|
|
|
/**
|
|
* CRITICAL ISOLATION LAYER FOR TEAM MEMBERS:
|
|
* Only fetch members that belong to THIS user's team.
|
|
* Members from other teams/layers are NEVER visible.
|
|
*/
|
|
const { data: teamMembers = [] } = useQuery({
|
|
queryKey: ['team-members', userTeam?.id, user?.id],
|
|
queryFn: async () => {
|
|
if (!userTeam?.id) {
|
|
console.log("⚠️ No team ID - returning empty members list");
|
|
return [];
|
|
}
|
|
|
|
// Fetch all members and filter by team_id
|
|
const allMembers = await base44.entities.TeamMember.list('-created_date');
|
|
|
|
// SECURITY: Only return members that belong to THIS user's team
|
|
const filteredMembers = allMembers.filter(m => m.team_id === userTeam.id);
|
|
|
|
console.log(`🔒 Loaded ${filteredMembers.length} members for team ${userTeam.id}`);
|
|
|
|
// VERIFICATION: Ensure all returned members belong to the correct team
|
|
const invalidMembers = filteredMembers.filter(m => m.team_id !== userTeam.id);
|
|
if (invalidMembers.length > 0) {
|
|
console.error("🚨 SECURITY VIOLATION: Found members from other teams!");
|
|
return [];
|
|
}
|
|
|
|
return filteredMembers;
|
|
},
|
|
enabled: !!userTeam?.id && !!user?.id,
|
|
initialData: [],
|
|
});
|
|
|
|
// Fetch pending invitations
|
|
const { data: pendingInvites = [] } = useQuery({
|
|
queryKey: ['team-invites', userTeam?.id],
|
|
queryFn: async () => {
|
|
if (!userTeam?.id) return [];
|
|
const allInvites = await base44.entities.TeamMemberInvite.list('-invited_date');
|
|
return allInvites.filter(inv => inv.team_id === userTeam.id && inv.invite_status === 'pending');
|
|
},
|
|
enabled: !!userTeam?.id,
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: allStaff = [] } = useQuery({
|
|
queryKey: ['staff-for-favorites'],
|
|
queryFn: () => base44.entities.Staff.list(),
|
|
enabled: !!userTeam?.id,
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: teamHubs = [] } = useQuery({
|
|
queryKey: ['team-hubs-main', userTeam?.id],
|
|
queryFn: async () => {
|
|
if (!userTeam?.id) return [];
|
|
const allHubs = await base44.entities.TeamHub.list('-created_date');
|
|
return allHubs.filter(h => h.team_id === userTeam.id);
|
|
},
|
|
enabled: !!userTeam?.id,
|
|
initialData: [],
|
|
});
|
|
|
|
// Get unique departments from both team settings and existing team members
|
|
const teamDepartments = userTeam?.departments || [];
|
|
const memberDepartments = [...new Set(teamMembers.map(m => m.department).filter(Boolean))];
|
|
const uniqueDepartments = [...new Set([...teamDepartments, ...memberDepartments])];
|
|
|
|
// Separate active and deactivated members
|
|
const activeMembers = teamMembers.filter(m => m.is_active !== false);
|
|
const deactivatedMembers = teamMembers.filter(m => m.is_active === false);
|
|
|
|
const createTestInviteMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (!userTeam?.id) {
|
|
throw new Error("Team not found. Cannot create test invite.");
|
|
}
|
|
if (!user?.email && !user?.full_name) {
|
|
throw new Error("User identity not found. Cannot create test invite.");
|
|
}
|
|
|
|
const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`;
|
|
|
|
// Use the first hub if available, or empty string
|
|
const firstHub = teamHubs.length > 0 ? teamHubs[0].hub_name : "";
|
|
const firstDept = uniqueDepartments.length > 0 ? uniqueDepartments[0] : "Operations";
|
|
|
|
const invite = await base44.entities.TeamMemberInvite.create({
|
|
team_id: userTeam.id,
|
|
team_name: userTeam.team_name || "Team",
|
|
invite_code: inviteCode,
|
|
email: "demo@example.com",
|
|
full_name: "Demo User",
|
|
role: "member",
|
|
hub: firstHub,
|
|
department: firstDept,
|
|
invited_by: user?.email || user?.full_name,
|
|
invite_status: "PENDING",
|
|
invited_date: new Date().toISOString(),
|
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
|
});
|
|
|
|
return invite;
|
|
},
|
|
onSuccess: (invite) => {
|
|
navigate(createPageUrl("Onboarding") + `?invite=${invite.invite_code}`);
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
title: "❌ Failed to Create Test Invite",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const inviteMemberMutation = useMutation({
|
|
mutationFn: async (data) => {
|
|
if (!userTeam?.id) {
|
|
throw new Error("No team found. Please try refreshing the page.");
|
|
}
|
|
|
|
if (!user?.email && !user?.full_name) {
|
|
throw new Error("Unable to identify who is sending the invite. Please try logging out and back in.");
|
|
}
|
|
|
|
// Create hub if it doesn't exist, or update hub with new department
|
|
const existingHub = teamHubs.find(h => h.hub_name === data.hub);
|
|
|
|
if (data.hub && !existingHub) {
|
|
// Create new hub with department
|
|
await base44.entities.TeamHub.create({
|
|
team_id: userTeam.id,
|
|
hub_name: data.hub,
|
|
address: "",
|
|
is_active: true,
|
|
departments: data.department ? [{ department_name: data.department, cost_center: "" }] : []
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
|
|
} else if (existingHub && data.department) {
|
|
// Add department to existing hub if it doesn't exist
|
|
const hubDepartments = existingHub.departments || [];
|
|
const departmentExists = hubDepartments.some(d => d.department_name === data.department);
|
|
|
|
if (!departmentExists) {
|
|
await base44.entities.TeamHub.update(existingHub.id, {
|
|
departments: [...hubDepartments, { department_name: data.department, cost_center: "" }]
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
|
|
}
|
|
}
|
|
|
|
const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`;
|
|
|
|
const invite = await base44.entities.TeamMemberInvite.create({
|
|
team_id: userTeam.id,
|
|
team_name: userTeam.team_name || "Team",
|
|
invite_code: inviteCode,
|
|
email: data.email,
|
|
full_name: data.full_name,
|
|
role: data.role,
|
|
hub: data.hub || "",
|
|
department: data.department || "",
|
|
invited_by: user?.email || user?.full_name,
|
|
invite_status: "PENDING",
|
|
invited_date: new Date().toISOString(),
|
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
|
});
|
|
|
|
const registerUrl = `${window.location.origin}${createPageUrl('Onboarding')}?invite=${inviteCode}`;
|
|
|
|
await base44.integrations.Core.SendEmail({
|
|
from_name: userTeam.team_name || "KROW",
|
|
to: data.email,
|
|
subject: `🚀 Welcome to KROW! You've been invited to ${data.hub || userTeam.team_name}`,
|
|
body: `
|
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
|
<div style="text-align: center; padding: 40px 20px; background: linear-gradient(135deg, #0A39DF 0%, #1C323E 100%); border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.3);">
|
|
<div style="font-size: 64px; margin-bottom: 16px;">🎉</div>
|
|
<h1 style="color: white; margin: 0; font-size: 32px; font-weight: 800;">You're Invited!</h1>
|
|
<p style="color: #93c5fd; margin-top: 12px; font-size: 18px; font-weight: 600;">Join ${data.hub ? `the ${data.hub} hub` : userTeam.team_name || 'our team'}</p>
|
|
</div>
|
|
|
|
<div style="padding: 40px; background: white; border-radius: 16px; margin-top: 20px; box-shadow: 0 4px 16px rgba(0,0,0,0.1);">
|
|
<p style="color: #1C323E; font-size: 18px; line-height: 1.6; font-weight: 600;">
|
|
Hi ${data.full_name || 'there'} 👋
|
|
</p>
|
|
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.8; margin-top: 20px;">
|
|
Great news! <strong>${user?.full_name || user?.email}</strong> has invited you to join <strong>${data.hub || userTeam.team_name || 'the team'}</strong> as a <strong style="color: #0A39DF;">${data.role}</strong>.
|
|
</p>
|
|
|
|
<div style="background: linear-gradient(135deg, #e0f2fe 0%, #ddd6fe 100%); padding: 24px; border-radius: 12px; margin: 32px 0; border-left: 6px solid #0A39DF;">
|
|
<h3 style="color: #1C323E; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">🚀 Why KROW?</h3>
|
|
<ul style="color: #475569; margin: 0; padding-left: 20px; line-height: 2; font-size: 15px;">
|
|
<li><strong>Seamless Operations:</strong> Manage your workforce effortlessly</li>
|
|
<li><strong>Smart Scheduling:</strong> AI-powered shift assignments</li>
|
|
<li><strong>Real-Time Updates:</strong> Stay connected with your team</li>
|
|
<li><strong>Simplified Workflow:</strong> Everything you need in one place</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div style="text-align: center; margin: 40px 0;">
|
|
<a href="${registerUrl}"
|
|
style="display: inline-block; padding: 20px 48px; background: linear-gradient(135deg, #0A39DF 0%, #1C323E 100%);
|
|
color: white; text-decoration: none; border-radius: 12px; font-weight: 800; font-size: 18px;
|
|
box-shadow: 0 8px 24px rgba(10, 57, 223, 0.4); transition: all 0.3s;">
|
|
🎯 Get Started Now →
|
|
</a>
|
|
</div>
|
|
|
|
<div style="background: #f8fafc; padding: 20px; border-radius: 12px; margin: 32px 0; border: 2px solid #e2e8f0;">
|
|
<h3 style="color: #1C323E; margin: 0 0 12px 0; font-size: 16px; font-weight: 700;">✅ Quick Setup (3 Steps):</h3>
|
|
<ol style="color: #475569; margin: 0; padding-left: 20px; line-height: 2; font-size: 15px;">
|
|
<li><strong>Click</strong> the button above to register</li>
|
|
<li><strong>Create</strong> your account with <span style="color: #0A39DF; font-weight: 600;">${data.email}</span></li>
|
|
<li><strong>Start</strong> managing your operations smoothly!</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 100%); padding: 20px; border-radius: 12px; margin-top: 32px; border: 2px solid #fbbf24;">
|
|
<p style="color: #92400e; font-size: 15px; margin: 0; font-weight: 600;">
|
|
⏰ <strong>Time-Sensitive:</strong> This invitation expires in 7 days. Don't miss out!
|
|
</p>
|
|
</div>
|
|
|
|
<div style="margin-top: 40px; padding-top: 24px; border-top: 2px solid #e2e8f0; text-align: center;">
|
|
<p style="color: #94a3b8; font-size: 13px; margin: 8px 0;">
|
|
Your invite code: <strong style="color: #475569; font-family: monospace; background: #f1f5f9; padding: 4px 8px; border-radius: 4px;">${inviteCode}</strong>
|
|
</p>
|
|
<p style="color: #94a3b8; font-size: 13px; margin: 8px 0;">
|
|
Questions? Reach out to <a href="mailto:${user?.email || 'support@krow.com'}" style="color: #0A39DF; font-weight: 600;">${user?.email || 'support'}</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="text-align: center; padding: 20px; color: #cbd5e1; font-size: 12px;">
|
|
<p style="margin: 0;">Powered by <strong>KROW</strong> - Workforce Control Tower</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
});
|
|
|
|
return { invite };
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
|
|
queryClient.invalidateQueries({ queryKey: ['team-invites', userTeam?.id] });
|
|
setShowInviteMemberDialog(false);
|
|
setPreSelectedHub(null);
|
|
setInviteData({
|
|
email: "",
|
|
full_name: "",
|
|
role: "member",
|
|
hub: "",
|
|
department: "",
|
|
});
|
|
toast({
|
|
title: "✅ Invitation Sent!",
|
|
description: `Email invitation sent to ${inviteData.email}`,
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
title: "❌ Failed to Send Invitation",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const resendInviteMutation = useMutation({
|
|
mutationFn: async (invite) => {
|
|
const registerUrl = `${window.location.origin}${createPageUrl('Onboarding')}?invite=${invite.invite_code}`;
|
|
|
|
await base44.integrations.Core.SendEmail({
|
|
from_name: userTeam.team_name || "Team",
|
|
to: invite.email,
|
|
subject: `Reminder: You're invited to join ${userTeam.team_name || 'our team'}!`,
|
|
body: `
|
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background: linear-gradient(to bottom, #f8fafc, #e0f2fe); border-radius: 12px;">
|
|
<div style="text-align: center; padding: 20px; background: linear-gradient(to right, #0A39DF, #1C323E); border-radius: 12px 12px 0 0;">
|
|
<h1 style="color: white; margin: 0;">📬 Reminder: Team Invitation</h1>
|
|
<p style="color: #e0f2fe; margin-top: 8px;">Join ${userTeam.team_name || 'our team'}</p>
|
|
</div>
|
|
|
|
<div style="padding: 30px; background: white; border-radius: 0 0 12px 12px;">
|
|
<p style="color: #1C323E; font-size: 16px; line-height: 1.6;">
|
|
Hi ${invite.full_name || 'there'},
|
|
</p>
|
|
|
|
<p style="color: #475569; font-size: 14px; line-height: 1.6; margin-top: 16px;">
|
|
This is a reminder that <strong>${invite.invited_by}</strong> has invited you to join <strong>${userTeam.team_name || 'our team'}</strong> as a <strong>${invite.role}</strong>.
|
|
</p>
|
|
|
|
<div style="text-align: center; margin: 32px 0;">
|
|
<a href="${registerUrl}"
|
|
style="display: inline-block; padding: 16px 32px; background: linear-gradient(to right, #0A39DF, #1C323E);
|
|
color: white; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px;
|
|
box-shadow: 0 4px 6px rgba(10, 57, 223, 0.3);">
|
|
Register & Join Team →
|
|
</a>
|
|
</div>
|
|
|
|
<div style="background: #dbeafe; padding: 16px; border-radius: 8px; margin-top: 24px; border: 1px solid #3b82f6;">
|
|
<p style="color: #1e40af; font-size: 13px; margin: 0;">
|
|
<strong>⏰ Important:</strong> This invitation will expire soon. Please register at your earliest convenience.
|
|
</p>
|
|
</div>
|
|
|
|
<p style="color: #64748b; font-size: 13px; margin-top: 32px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
|
|
Your invite code: <strong>${invite.invite_code}</strong><br>
|
|
Questions? Contact ${user?.email || 'the team admin'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
});
|
|
|
|
return invite;
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "✅ Invitation Resent!",
|
|
description: "The invitation has been sent again",
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
title: "❌ Failed to Resend",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const updateMemberMutation = useMutation({
|
|
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
|
|
setShowEditMemberDialog(false);
|
|
setEditingMember(null);
|
|
toast({
|
|
title: "✅ Member Updated",
|
|
description: "Team member updated successfully",
|
|
});
|
|
},
|
|
});
|
|
|
|
const deactivateMemberMutation = useMutation({
|
|
mutationFn: ({ id }) => base44.entities.TeamMember.update(id, { is_active: false }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
|
|
toast({
|
|
title: "✅ Member Deactivated",
|
|
description: "Team member has been deactivated",
|
|
});
|
|
},
|
|
});
|
|
|
|
const activateMemberMutation = useMutation({
|
|
mutationFn: ({ id }) => base44.entities.TeamMember.update(id, { is_active: true }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
|
|
toast({
|
|
title: "✅ Member Activated",
|
|
description: "Team member has been activated",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleInviteMember = () => {
|
|
inviteMemberMutation.mutate(inviteData);
|
|
};
|
|
|
|
const handleEditMember = (member) => {
|
|
setEditingMember(member);
|
|
setShowEditMemberDialog(true);
|
|
};
|
|
|
|
const handleUpdateMember = () => {
|
|
updateMemberMutation.mutate({
|
|
id: editingMember.id,
|
|
data: editingMember
|
|
});
|
|
};
|
|
|
|
const handleDeactivateMember = (member) => {
|
|
deactivateMemberMutation.mutate({ id: member.id });
|
|
};
|
|
|
|
const handleActivateMember = (member) => {
|
|
activateMemberMutation.mutate({ id: member.id });
|
|
};
|
|
|
|
const handleAddDepartment = () => {
|
|
setEditingDepartment(null);
|
|
setNewDepartment("");
|
|
setShowDepartmentDialog(true);
|
|
};
|
|
|
|
const handleSaveDepartment = async () => {
|
|
if (!newDepartment.trim()) {
|
|
toast({
|
|
title: "⚠️ Invalid Department",
|
|
description: "Department name cannot be empty",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!userTeam?.id) {
|
|
toast({
|
|
title: "⚠️ Error",
|
|
description: "Team not found. Please refresh the page.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const currentDepartments = userTeam.departments || [];
|
|
let updatedDepartments;
|
|
|
|
if (editingDepartment) {
|
|
// Update existing department
|
|
updatedDepartments = currentDepartments.map(dept =>
|
|
dept === editingDepartment ? newDepartment.trim() : dept
|
|
);
|
|
} else {
|
|
// Add new department
|
|
if (currentDepartments.includes(newDepartment.trim())) {
|
|
toast({
|
|
title: "⚠️ Duplicate Department",
|
|
description: "This department already exists",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
updatedDepartments = [...currentDepartments, newDepartment.trim()];
|
|
}
|
|
|
|
// Update the team with new departments list
|
|
await base44.entities.Team.update(userTeam.id, {
|
|
departments: updatedDepartments
|
|
});
|
|
|
|
// Refresh team data
|
|
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });
|
|
|
|
toast({
|
|
title: "✅ Department Saved",
|
|
description: `Department "${newDepartment}" has been ${editingDepartment ? 'updated' : 'added'}`,
|
|
});
|
|
|
|
setShowDepartmentDialog(false);
|
|
setEditingDepartment(null);
|
|
setNewDepartment("");
|
|
} catch (error) {
|
|
toast({
|
|
title: "❌ Error",
|
|
description: "Failed to save department. Please try again.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteDepartment = async (deptToDelete) => {
|
|
if (!userTeam?.id) return;
|
|
|
|
try {
|
|
const currentDepartments = userTeam.departments || [];
|
|
const updatedDepartments = currentDepartments.filter(dept => dept !== deptToDelete);
|
|
|
|
await base44.entities.Team.update(userTeam.id, {
|
|
departments: updatedDepartments
|
|
});
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });
|
|
|
|
toast({
|
|
title: "✅ Department Deleted",
|
|
description: `Department "${deptToDelete}" has been removed`,
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "❌ Error",
|
|
description: "Failed to delete department. Please try again.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const updateTeamMutation = useMutation({
|
|
mutationFn: ({ id, data }) => base44.entities.Team.update(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });
|
|
toast({
|
|
title: "✅ Team Updated",
|
|
description: "Team updated successfully",
|
|
});
|
|
},
|
|
});
|
|
|
|
const addToFavorites = (staff) => {
|
|
const favoriteStaff = userTeam.favorite_staff || [];
|
|
const newFavorite = {
|
|
staff_id: staff.id,
|
|
staff_name: staff.employee_name,
|
|
position: staff.position,
|
|
added_date: new Date().toISOString()
|
|
};
|
|
|
|
updateTeamMutation.mutate({
|
|
id: userTeam.id,
|
|
data: {
|
|
favorite_staff: [...favoriteStaff, newFavorite],
|
|
favorite_staff_count: favoriteStaff.length + 1
|
|
}
|
|
});
|
|
setShowAddFavoriteDialog(false);
|
|
};
|
|
|
|
const removeFromFavorites = (staffId) => {
|
|
const favoriteStaff = (userTeam.favorite_staff || []).filter(f => f.staff_id !== staffId);
|
|
updateTeamMutation.mutate({
|
|
id: userTeam.id,
|
|
data: {
|
|
favorite_staff: favoriteStaff,
|
|
favorite_staff_count: favoriteStaff.length
|
|
}
|
|
});
|
|
};
|
|
|
|
const addToBlocked = (staff) => {
|
|
const blockedStaff = userTeam.blocked_staff || [];
|
|
const newBlocked = {
|
|
staff_id: staff.id,
|
|
staff_name: staff.employee_name,
|
|
reason: blockReason,
|
|
blocked_date: new Date().toISOString()
|
|
};
|
|
|
|
updateTeamMutation.mutate({
|
|
id: userTeam.id,
|
|
data: {
|
|
blocked_staff: [...blockedStaff, newBlocked],
|
|
blocked_staff_count: blockedStaff.length + 1
|
|
}
|
|
});
|
|
setShowAddBlockedDialog(false);
|
|
setBlockReason("");
|
|
};
|
|
|
|
const removeFromBlocked = (staffId) => {
|
|
const blockedStaff = (userTeam.blocked_staff || []).filter(b => b.staff_id !== staffId);
|
|
updateTeamMutation.mutate({
|
|
id: userTeam.id,
|
|
data: {
|
|
blocked_staff: blockedStaff,
|
|
blocked_staff_count: blockedStaff.length
|
|
}
|
|
});
|
|
};
|
|
|
|
// Load Google Maps script
|
|
useEffect(() => {
|
|
if (window.google?.maps?.places) {
|
|
setIsGoogleMapsLoaded(true);
|
|
return;
|
|
}
|
|
|
|
const script = document.createElement('script');
|
|
script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyBkP7xH4NvR6C6vZ8Y3J7qX2QW8Z9vN3Zc&libraries=places`;
|
|
script.async = true;
|
|
script.onload = () => setIsGoogleMapsLoaded(true);
|
|
document.head.appendChild(script);
|
|
}, []);
|
|
|
|
// Initialize autocomplete
|
|
useEffect(() => {
|
|
if (isGoogleMapsLoaded && addressInputRef.current && showAddHubDialog && !autocompleteRef.current) {
|
|
autocompleteRef.current = new window.google.maps.places.Autocomplete(addressInputRef.current, {
|
|
types: ['address'],
|
|
componentRestrictions: { country: 'us' }
|
|
});
|
|
|
|
autocompleteRef.current.addListener('place_changed', () => {
|
|
const place = autocompleteRef.current.getPlace();
|
|
if (place.formatted_address) {
|
|
setNewHub({ ...newHub, address: place.formatted_address });
|
|
}
|
|
});
|
|
}
|
|
}, [isGoogleMapsLoaded, showAddHubDialog]);
|
|
|
|
const createHubMutation = useMutation({
|
|
mutationFn: (hubData) => base44.entities.TeamHub.create({
|
|
...hubData,
|
|
team_id: userTeam.id,
|
|
is_active: true
|
|
}),
|
|
onSuccess: (createdHub) => {
|
|
queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
|
|
setShowAddHubDialog(false);
|
|
const hubName = newHub.hub_name;
|
|
setNewHub({
|
|
hub_name: "",
|
|
address: "",
|
|
manager_name: "",
|
|
manager_position: "",
|
|
manager_email: ""
|
|
});
|
|
autocompleteRef.current = null;
|
|
|
|
// Show success with invite action
|
|
toast({
|
|
title: "✅ Hub Created Successfully!",
|
|
description: (
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span>Ready to invite members to {hubName}?</span>
|
|
<Button
|
|
size="sm"
|
|
className="bg-[#0A39DF] hover:bg-blue-700 h-7 px-3"
|
|
onClick={() => {
|
|
setPreSelectedHub(hubName);
|
|
setInviteData({ ...inviteData, hub: hubName });
|
|
setShowInviteMemberDialog(true);
|
|
}}
|
|
>
|
|
Invite Now
|
|
</Button>
|
|
</div>
|
|
),
|
|
});
|
|
},
|
|
});
|
|
|
|
const filteredMembers = (members) => members.filter(member => {
|
|
const matchesSearch = !searchTerm ||
|
|
member.member_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
member.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
member.title?.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
const matchesDepartment = departmentFilter === "all" || member.department === departmentFilter;
|
|
|
|
return matchesSearch && matchesDepartment;
|
|
});
|
|
|
|
const getRoleTitle = () => {
|
|
const titles = {
|
|
admin: "Admin Team",
|
|
procurement: "Procurement Team",
|
|
operator: "Operator Team",
|
|
sector: "Sector Team",
|
|
client: "Client Team",
|
|
vendor: "Vendor Team",
|
|
workforce: "Workforce Team"
|
|
};
|
|
return titles[userRole] || "Team";
|
|
};
|
|
|
|
const getIsolatedSubtitle = () => {
|
|
return `${activeMembers.length} active • ${deactivatedMembers.length} deactivated • ${pendingInvites.length} pending invites`;
|
|
};
|
|
|
|
const renderMemberCard = (member) => (
|
|
<Card
|
|
key={member.id}
|
|
className={`group border-2 transition-all duration-300 ${
|
|
member.is_active
|
|
? 'border-slate-200 hover:border-[#0A39DF] hover:shadow-2xl hover:-translate-y-1'
|
|
: 'border-slate-200 bg-slate-50/50 opacity-70'
|
|
}`}
|
|
>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className={`relative w-14 h-14 rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-lg ${
|
|
member.is_active
|
|
? 'bg-gradient-to-br from-[#0A39DF] to-[#1C323E]'
|
|
: 'bg-gradient-to-br from-slate-400 to-slate-600'
|
|
}`}>
|
|
{member.member_name?.split(' ').map(n => n[0]).join('') || '?'}
|
|
{!member.is_active && (
|
|
<div className="absolute -top-1 -right-1 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center ring-2 ring-white">
|
|
<UserX className="w-3 h-3 text-white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-bold text-[#1C323E] text-lg group-hover:text-[#0A39DF] transition-colors truncate">
|
|
{member.member_name}
|
|
</h4>
|
|
<p className="text-sm text-slate-600 capitalize">{member.role}</p>
|
|
{member.title && (
|
|
<p className="text-xs text-slate-500 mt-0.5 truncate">{member.title}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 text-sm mb-4">
|
|
{member.email && (
|
|
<div className="flex items-center gap-2 text-slate-600 hover:text-[#0A39DF] transition-colors">
|
|
<Mail className="w-3 h-3 flex-shrink-0" />
|
|
<span className="truncate">{member.email}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{member.department && (
|
|
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
|
{member.department}
|
|
</Badge>
|
|
)}
|
|
{member.hub && (
|
|
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200 flex items-center gap-1">
|
|
<MapPin className="w-3 h-3" />
|
|
{member.hub}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
|
|
onClick={() => handleEditMember(member)}
|
|
>
|
|
<Edit className="w-3 h-3 mr-1" />
|
|
Edit
|
|
</Button>
|
|
{member.is_active ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-red-600 hover:bg-red-50 hover:border-red-300 transition-all"
|
|
onClick={() => handleDeactivateMember(member)}
|
|
>
|
|
<UserX className="w-3 h-3" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-green-600 hover:bg-green-50 hover:border-green-300 transition-all"
|
|
onClick={() => handleActivateMember(member)}
|
|
>
|
|
<UserCheck className="w-3 h-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
|
<div className="p-4 md:p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Security Notice Banner */}
|
|
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-l-4 border-[#0A39DF] rounded-lg shadow-sm">
|
|
<p className="text-sm text-blue-900 flex items-center gap-2">
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
|
</svg>
|
|
<strong>Isolated Team:</strong> You can only see and manage YOUR team members.
|
|
{userRole === 'vendor' && " Procurement teams are NOT visible to you."}
|
|
{userRole === 'procurement' && " Vendor teams are NOT visible to you."}
|
|
{userRole === 'operator' && " Other layer teams are NOT visible to you."}
|
|
</p>
|
|
</div>
|
|
|
|
<PageHeader
|
|
title={getRoleTitle()}
|
|
subtitle={getIsolatedSubtitle()}
|
|
/>
|
|
|
|
{/* Team Members Section */}
|
|
<Card className="mb-6 border-slate-200 shadow-xl">
|
|
<CardHeader className="bg-gradient-to-br from-white to-slate-50 border-b border-slate-100">
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#0A39DF] to-[#1C323E] flex items-center justify-center shadow-lg">
|
|
<UserCheck className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-xl text-[#1C323E]">Team Management</CardTitle>
|
|
<p className="text-sm text-slate-500 mt-1">Manage roles, permissions, and team organization</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 flex-wrap">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowAddHubDialog(true)}
|
|
className="hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create Hub
|
|
</Button>
|
|
<Button
|
|
onClick={() => createTestInviteMutation.mutate()}
|
|
disabled={createTestInviteMutation.isPending || !userTeam?.id}
|
|
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg font-bold"
|
|
size="lg"
|
|
>
|
|
{createTestInviteMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
Loading...
|
|
</>
|
|
) : (
|
|
<>
|
|
🎯 Get Started Now →
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg transition-all"
|
|
onClick={() => setShowInviteMemberDialog(true)}
|
|
>
|
|
<Mail className="w-4 h-4 mr-2" />
|
|
Invite Member
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-6">
|
|
{/* Filters and View Toggle */}
|
|
<div className="flex items-center gap-4 mb-6 flex-wrap">
|
|
<div className="relative flex-1 min-w-[240px]">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
|
<Input
|
|
placeholder="Search team members..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 border-slate-300 focus:border-[#0A39DF] transition-all"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="w-4 h-4 text-slate-500" />
|
|
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
|
|
<SelectTrigger className="w-48 border-slate-300">
|
|
<SelectValue placeholder="All Departments" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Departments</SelectItem>
|
|
{uniqueDepartments.map(dept => (
|
|
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg shadow-sm">
|
|
<Button
|
|
size="sm"
|
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
|
onClick={() => setViewMode("grid")}
|
|
className={viewMode === "grid" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90 shadow-sm" : "hover:bg-white"}
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={viewMode === "list" ? "default" : "ghost"}
|
|
onClick={() => setViewMode("list")}
|
|
className={viewMode === "list" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90 shadow-sm" : "hover:bg-white"}
|
|
>
|
|
<ListIcon className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs for Active, Deactivated, Invitations, Hubs, Favorites, Blocked */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="grid w-full grid-cols-6 mb-6 bg-slate-100 p-1">
|
|
<TabsTrigger value="active" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
|
|
<UserCheck className="w-4 h-4" />
|
|
Active ({activeMembers.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="deactivated" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
|
|
<UserX className="w-4 h-4" />
|
|
Deactivated ({deactivatedMembers.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="invitations" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
|
|
<Mail className="w-4 h-4" />
|
|
Invitations ({pendingInvites.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="hubs" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
|
|
<MapPin className="w-4 h-4" />
|
|
Hubs ({teamHubs.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="favorites" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
|
|
<Star className="w-4 h-4" />
|
|
Favorites ({userTeam?.favorite_staff_count || 0})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="blocked" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
|
|
<UserX className="w-4 h-4" />
|
|
Blocked ({userTeam?.blocked_staff_count || 0})
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Active Members Tab */}
|
|
<TabsContent value="active">
|
|
{viewMode === "grid" && filteredMembers(activeMembers).length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filteredMembers(activeMembers).map(renderMemberCard)}
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === "list" && filteredMembers(activeMembers).length > 0 && (
|
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-slate-50 border-b border-slate-200">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">#</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Name</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Title</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Role</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Department</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{filteredMembers(activeMembers).map((member, index) => {
|
|
const memberHub = teamHubs.find(h => h.hub_name === member.hub);
|
|
return (
|
|
<tr key={member.id} className="hover:bg-slate-50 transition-colors">
|
|
<td className="px-4 py-4 text-sm text-slate-600">{index + 1}</td>
|
|
<td className="px-4 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
|
{member.member_name?.split(' ').map(n => n[0]).join('') || '?'}
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-slate-900">{member.member_name}</p>
|
|
<p className="text-xs text-slate-500">{member.email}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-700">{member.title || '-'}</td>
|
|
<td className="px-4 py-4">
|
|
<Badge variant="outline" className="capitalize text-xs">
|
|
{member.role}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-700">{member.department || 'No Department'}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
|
|
<td className="px-4 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleEditMember(member)}
|
|
className="h-8 w-8 hover:bg-blue-50 hover:text-[#0A39DF]"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleDeactivateMember(member)}
|
|
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
|
|
>
|
|
<UserX className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{filteredMembers(activeMembers).length === 0 && (
|
|
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-200">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center">
|
|
<UserCheck className="w-8 h-8 text-slate-400" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Active Members</h3>
|
|
<p className="text-slate-500 mb-6">
|
|
{activeMembers.length === 0
|
|
? 'Invite your first team member to get started'
|
|
: 'No active members match your filters'}
|
|
</p>
|
|
{activeMembers.length === 0 && (
|
|
<Button
|
|
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 shadow-lg"
|
|
onClick={() => setShowInviteMemberDialog(true)}
|
|
>
|
|
<Mail className="w-4 h-4 mr-2" />
|
|
Invite First Member
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Deactivated Members Tab */}
|
|
<TabsContent value="deactivated">
|
|
{viewMode === "grid" && filteredMembers(deactivatedMembers).length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filteredMembers(deactivatedMembers).map(renderMemberCard)}
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === "list" && filteredMembers(deactivatedMembers).length > 0 && (
|
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden opacity-70">
|
|
<table className="w-full">
|
|
<thead className="bg-slate-50 border-b border-slate-200">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">#</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Name</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Title</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Role</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Department</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{filteredMembers(deactivatedMembers).map((member, index) => {
|
|
const memberHub = teamHubs.find(h => h.hub_name === member.hub);
|
|
return (
|
|
<tr key={member.id} className="hover:bg-slate-50 transition-colors">
|
|
<td className="px-4 py-4 text-sm text-slate-600">{index + 1}</td>
|
|
<td className="px-4 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative w-10 h-10 bg-gradient-to-br from-slate-400 to-slate-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
|
{member.member_name?.split(' ').map(n => n[0]).join('') || '?'}
|
|
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center ring-2 ring-white">
|
|
<UserX className="w-2.5 h-2.5 text-white" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-slate-900">{member.member_name}</p>
|
|
<p className="text-xs text-slate-500">{member.email}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-700">{member.title || '-'}</td>
|
|
<td className="px-4 py-4">
|
|
<Badge variant="outline" className="capitalize text-xs">
|
|
{member.role}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-700">{member.department || 'No Department'}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
|
|
<td className="px-4 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleEditMember(member)}
|
|
className="h-8 w-8 hover:bg-blue-50 hover:text-[#0A39DF]"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleActivateMember(member)}
|
|
className="h-8 w-8 hover:bg-green-50 hover:text-green-600"
|
|
>
|
|
<UserCheck className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{filteredMembers(deactivatedMembers).length === 0 && (
|
|
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-200">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-green-100 to-emerald-200 flex items-center justify-center">
|
|
<UserCheck className="w-8 h-8 text-green-600" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Deactivated Members</h3>
|
|
<p className="text-slate-500">All your team members are currently active</p>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Pending Invitations Tab */}
|
|
<TabsContent value="invitations">
|
|
{pendingInvites.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{pendingInvites.map((invite) => (
|
|
<div
|
|
key={invite.id}
|
|
className="flex items-center justify-between p-5 rounded-xl border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 hover:shadow-lg transition-all"
|
|
>
|
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center text-white shadow-lg flex-shrink-0">
|
|
<Mail className="w-7 h-7" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-bold text-[#1C323E] truncate">{invite.full_name}</h4>
|
|
<p className="text-sm text-slate-600 truncate">{invite.email}</p>
|
|
<div className="flex gap-2 mt-2 flex-wrap">
|
|
<Badge variant="outline" className="text-xs capitalize bg-white border-blue-300 text-blue-700">
|
|
{invite.role}
|
|
</Badge>
|
|
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
Invited {new Date(invite.invited_date).toLocaleDateString()}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => resendInviteMutation.mutate(invite)}
|
|
disabled={resendInviteMutation.isPending}
|
|
className="text-[#0A39DF] border-[#0A39DF] hover:bg-[#0A39DF] hover:text-white transition-all flex-shrink-0"
|
|
>
|
|
{resendInviteMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Sending...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send className="w-4 h-4 mr-2" />
|
|
Resend
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-200">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-blue-100 to-indigo-200 flex items-center justify-center">
|
|
<Mail className="w-8 h-8 text-blue-600" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Pending Invitations</h3>
|
|
<p className="text-slate-500 mb-6">All invitations have been accepted or expired</p>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Hubs Tab */}
|
|
<TabsContent value="hubs">
|
|
<div className="space-y-6">
|
|
{teamHubs.length > 0 ? (
|
|
viewMode === "grid" ? (
|
|
<div className="space-y-6">
|
|
{teamHubs.map((hub) => (
|
|
<div key={hub.id} className="bg-white rounded-xl border-2 border-slate-200 overflow-hidden hover:shadow-xl transition-all duration-300">
|
|
{/* Hub Header */}
|
|
<div className="bg-gradient-to-r from-slate-50 via-blue-50 to-slate-50 border-b-2 border-slate-200 p-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div className="flex items-start gap-4 flex-1">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-2xl flex items-center justify-center shadow-lg">
|
|
<Building2 className="w-8 h-8 text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-2xl font-bold text-[#1C323E] mb-2">{hub.hub_name}</h3>
|
|
{hub.address && (
|
|
<div className="flex items-start gap-2 text-slate-600 mb-3">
|
|
<MapPin className="w-4 h-4 mt-1 flex-shrink-0" />
|
|
<p className="text-sm">{hub.address}</p>
|
|
</div>
|
|
)}
|
|
{hub.manager_name && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<div className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-lg border border-slate-200">
|
|
<UserCheck className="w-4 h-4 text-[#0A39DF]" />
|
|
<span className="font-medium text-slate-700">{hub.manager_name}</span>
|
|
</div>
|
|
{hub.manager_email && (
|
|
<a href={`mailto:${hub.manager_email}`} className="flex items-center gap-2 text-slate-600 hover:text-[#0A39DF] transition-colors">
|
|
<Mail className="w-4 h-4" />
|
|
{hub.manager_email}
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
className="bg-[#0A39DF] hover:bg-blue-700 text-white"
|
|
onClick={() => {
|
|
setPreSelectedHub(hub.hub_name);
|
|
setInviteData({ ...inviteData, hub: hub.hub_name });
|
|
setShowInviteMemberDialog(true);
|
|
}}
|
|
>
|
|
<UserPlus className="w-4 h-4 mr-2" />
|
|
Invite Member
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Stats */}
|
|
<div className="flex items-center gap-6 mt-4">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
<Users className="w-4 h-4 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-slate-500 font-medium">Team Members</p>
|
|
<p className="text-lg font-bold text-slate-900">{activeMembers.filter(m => m.hub === hub.hub_name).length}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
<Building2 className="w-4 h-4 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-slate-500 font-medium">Departments</p>
|
|
<p className="text-lg font-bold text-slate-900">{hub.departments?.length || 0}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Departments Section */}
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h4 className="text-lg font-bold text-[#1C323E] flex items-center gap-2">
|
|
<Building2 className="w-5 h-5 text-[#0A39DF]" />
|
|
Departments
|
|
</h4>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedHubForDept(hub);
|
|
setShowAddHubDepartmentDialog(true);
|
|
}}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create Department
|
|
</Button>
|
|
</div>
|
|
|
|
{hub.departments && hub.departments.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{hub.departments.map((dept, idx) => (
|
|
<div key={idx} className="bg-slate-50 rounded-xl border border-slate-200 overflow-hidden hover:border-[#0A39DF] transition-all">
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-blue-600 rounded-lg flex items-center justify-center">
|
|
<Building2 className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<h5 className="font-bold text-slate-900">{dept.department_name}</h5>
|
|
{dept.cost_center && (
|
|
<p className="text-xs text-slate-500 font-mono mt-0.5">Cost Center: {dept.cost_center}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{dept.manager_name && (
|
|
<Badge className="bg-blue-100 text-blue-700 border-blue-200">
|
|
{dept.manager_name}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Team members in this department */}
|
|
{activeMembers.filter(m => m.hub === hub.hub_name && m.department === dept.department_name).length > 0 && (
|
|
<div className="mt-3 pt-3 border-t border-slate-200">
|
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
|
Team ({activeMembers.filter(m => m.hub === hub.hub_name && m.department === dept.department_name).length})
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{activeMembers.filter(m => m.hub === hub.hub_name && m.department === dept.department_name).map((member) => (
|
|
<div key={member.id} className="flex items-center gap-2 bg-white px-3 py-2 rounded-lg border border-slate-200 hover:border-[#0A39DF] transition-all group cursor-pointer" onClick={() => handleEditMember(member)}>
|
|
<div className="w-7 h-7 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white font-bold text-xs">
|
|
{member.member_name?.split(' ').map(n => n[0]).join('') || '?'}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-900 group-hover:text-[#0A39DF] transition-colors">{member.member_name}</p>
|
|
<p className="text-xs text-slate-500">{member.title || member.role}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 bg-slate-50 rounded-xl border-2 border-dashed border-slate-200">
|
|
<Building2 className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
|
<p className="text-sm text-slate-500 font-medium">No departments yet</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-4"
|
|
onClick={() => {
|
|
setSelectedHubForDept(hub);
|
|
setShowAddHubDepartmentDialog(true);
|
|
}}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create First Department
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Add New Hub Button */}
|
|
<Button
|
|
onClick={() => setShowAddHubDialog(true)}
|
|
size="lg"
|
|
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg h-16"
|
|
>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Create New Hub
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Hub Name</TableHead>
|
|
<TableHead>Address</TableHead>
|
|
<TableHead>Manager</TableHead>
|
|
<TableHead>Departments</TableHead>
|
|
<TableHead>Members</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{teamHubs.map((hub) => (
|
|
<TableRow key={hub.id} className="hover:bg-blue-50">
|
|
<TableCell>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center">
|
|
<Building2 className="w-5 h-5 text-white" />
|
|
</div>
|
|
<span className="font-bold text-[#1C323E]">{hub.hub_name}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-start gap-2">
|
|
<MapPin className="w-4 h-4 text-slate-400 mt-0.5 flex-shrink-0" />
|
|
<span className="text-sm text-slate-600">{hub.address || '—'}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div>
|
|
<p className="font-medium text-slate-900">{hub.manager_name || '—'}</p>
|
|
{hub.manager_email && (
|
|
<p className="text-xs text-slate-500">{hub.manager_email}</p>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className="bg-purple-100 text-purple-700 border-purple-200">
|
|
{hub.departments?.length || 0} depts
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className="bg-blue-100 text-blue-700 border-blue-200">
|
|
{activeMembers.filter(m => m.hub === hub.hub_name).length} members
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedHubForDept(hub);
|
|
setShowAddHubDepartmentDialog(true);
|
|
}}
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Dept
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="bg-[#0A39DF] hover:bg-blue-700"
|
|
onClick={() => {
|
|
setPreSelectedHub(hub.hub_name);
|
|
setInviteData({ ...inviteData, hub: hub.hub_name });
|
|
setShowInviteMemberDialog(true);
|
|
}}
|
|
>
|
|
<UserPlus className="w-4 h-4 mr-1" />
|
|
Invite
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<Button
|
|
onClick={() => setShowAddHubDialog(true)}
|
|
size="lg"
|
|
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg h-12"
|
|
>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Create New Hub
|
|
</Button>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="text-center py-20 bg-gradient-to-br from-white to-blue-50 rounded-2xl border-2 border-dashed border-blue-200">
|
|
<div className="w-24 h-24 mx-auto mb-6 rounded-full bg-gradient-to-br from-blue-100 to-indigo-100 flex items-center justify-center">
|
|
<Building2 className="w-12 h-12 text-blue-600" />
|
|
</div>
|
|
<h3 className="text-2xl font-bold text-slate-800 mb-3">No Hubs Yet</h3>
|
|
<p className="text-slate-600 mb-8 max-w-md mx-auto">
|
|
Create your first hub location to organize your team by physical locations and departments
|
|
</p>
|
|
<Button size="lg" className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg" onClick={() => setShowAddHubDialog(true)}>
|
|
<MapPin className="w-5 h-5 mr-2" />
|
|
Create First Hub
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Favorites Tab */}
|
|
<TabsContent value="favorites">
|
|
<div className="space-y-6">
|
|
{/* Header Section */}
|
|
<div className="bg-gradient-to-r from-amber-50 via-yellow-50 to-amber-50 rounded-2xl border-2 border-amber-200 p-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-2xl font-bold text-[#1C323E] flex items-center gap-3 mb-2">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-amber-400 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
|
<Star className="w-6 h-6 text-white fill-white" />
|
|
</div>
|
|
Preferred Staff
|
|
</h3>
|
|
<p className="text-slate-600 text-sm">Your go-to professionals for high-priority assignments</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-right">
|
|
<p className="text-3xl font-bold text-amber-600">{userTeam?.favorite_staff_count || 0}</p>
|
|
<p className="text-xs text-slate-500 font-medium">Favorites</p>
|
|
</div>
|
|
<Button onClick={() => setShowAddFavoriteDialog(true)} className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white shadow-lg">
|
|
<Star className="w-4 h-4 mr-2" />
|
|
Add Favorite
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-amber-500" />
|
|
<Input
|
|
placeholder="Search your favorite staff by name or role..."
|
|
value={favoriteSearch}
|
|
onChange={(e) => setFavoriteSearch(e.target.value)}
|
|
className="pl-12 h-12 bg-white border-2 border-amber-200 focus:border-amber-400 text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{userTeam?.favorite_staff && userTeam.favorite_staff.length > 0 ? (
|
|
viewMode === "grid" ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{userTeam.favorite_staff.filter(f =>
|
|
!favoriteSearch ||
|
|
f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) ||
|
|
f.position?.toLowerCase().includes(favoriteSearch.toLowerCase())
|
|
).map((fav) => (
|
|
<div key={fav.staff_id} className="group bg-gradient-to-br from-white to-amber-50 rounded-2xl border-2 border-amber-200 hover:border-amber-400 hover:shadow-2xl transition-all duration-300 overflow-hidden">
|
|
<div className="p-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div className="flex items-start gap-3 flex-1">
|
|
<div className="relative">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-amber-400 to-orange-500 rounded-2xl flex items-center justify-center text-white font-bold text-xl shadow-lg">
|
|
{fav.staff_name?.charAt(0)}
|
|
</div>
|
|
<div className="absolute -top-1 -right-1 w-7 h-7 bg-amber-500 rounded-full flex items-center justify-center shadow-md">
|
|
<Star className="w-4 h-4 text-white fill-white" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-bold text-lg text-slate-900 truncate group-hover:text-amber-600 transition-colors">{fav.staff_name}</h4>
|
|
<p className="text-sm text-slate-600 font-medium">{fav.position}</p>
|
|
<p className="text-xs text-amber-600 mt-1 bg-amber-100 px-2 py-1 rounded-full inline-block">
|
|
Added {new Date(fav.added_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => removeFromFavorites(fav.staff_id)}
|
|
className="flex-1 border-2 border-amber-300 text-amber-700 hover:bg-amber-100 hover:border-amber-400 font-semibold"
|
|
>
|
|
<Star className="w-4 h-4 mr-2" />
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Staff Member</TableHead>
|
|
<TableHead>Position</TableHead>
|
|
<TableHead>Added Date</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{userTeam.favorite_staff.filter(f =>
|
|
!favoriteSearch ||
|
|
f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) ||
|
|
f.position?.toLowerCase().includes(favoriteSearch.toLowerCase())
|
|
).map((fav) => (
|
|
<TableRow key={fav.staff_id} className="hover:bg-amber-50">
|
|
<TableCell>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-amber-400 to-orange-500 rounded-lg flex items-center justify-center text-white font-bold">
|
|
{fav.staff_name?.charAt(0)}
|
|
</div>
|
|
<span className="font-semibold">{fav.staff_name}</span>
|
|
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{fav.position}</TableCell>
|
|
<TableCell>
|
|
<span className="text-xs text-slate-600">
|
|
{new Date(fav.added_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => removeFromFavorites(fav.staff_id)}
|
|
className="border-amber-300 text-amber-700 hover:bg-amber-100"
|
|
>
|
|
Remove
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
) : (
|
|
<div className="text-center py-24 bg-gradient-to-br from-white to-amber-50 rounded-2xl border-2 border-dashed border-amber-200">
|
|
<div className="w-24 h-24 mx-auto mb-6 rounded-full bg-gradient-to-br from-amber-100 to-orange-100 flex items-center justify-center">
|
|
<Star className="w-12 h-12 text-amber-500" />
|
|
</div>
|
|
<h3 className="text-2xl font-bold text-slate-800 mb-3">No Favorites Yet</h3>
|
|
<p className="text-slate-600 mb-8 max-w-md mx-auto">
|
|
Build your dream team by marking your most reliable and skilled staff members as favorites
|
|
</p>
|
|
<Button onClick={() => setShowAddFavoriteDialog(true)} size="lg" className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white shadow-lg">
|
|
<Star className="w-5 h-5 mr-2" />
|
|
Add Your First Favorite
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Blocked Staff Tab */}
|
|
<TabsContent value="blocked">
|
|
<div className="space-y-6">
|
|
{/* Header Section */}
|
|
<div className="bg-gradient-to-r from-red-50 via-orange-50 to-red-50 rounded-2xl border-2 border-red-200 p-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-2xl font-bold text-[#1C323E] flex items-center gap-3 mb-2">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-red-700 rounded-xl flex items-center justify-center shadow-lg">
|
|
<UserX className="w-6 h-6 text-white" />
|
|
</div>
|
|
Blocked Staff
|
|
</h3>
|
|
<p className="text-slate-600 text-sm">Staff members excluded from future assignments</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-right">
|
|
<p className="text-3xl font-bold text-red-600">{userTeam?.blocked_staff_count || 0}</p>
|
|
<p className="text-xs text-slate-500 font-medium">Blocked</p>
|
|
</div>
|
|
<Button onClick={() => setShowAddBlockedDialog(true)} variant="outline" className="border-2 border-red-300 text-red-700 hover:bg-red-50 hover:border-red-400 font-semibold shadow-sm">
|
|
<UserX className="w-4 h-4 mr-2" />
|
|
Block Staff
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-red-500" />
|
|
<Input
|
|
placeholder="Search blocked staff by name..."
|
|
value={blockedSearch}
|
|
onChange={(e) => setBlockedSearch(e.target.value)}
|
|
className="pl-12 h-12 bg-white border-2 border-red-200 focus:border-red-400 text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{userTeam?.blocked_staff && userTeam.blocked_staff.length > 0 ? (
|
|
viewMode === "grid" ? (
|
|
<div className="space-y-4">
|
|
{userTeam.blocked_staff.filter(b =>
|
|
!blockedSearch ||
|
|
b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase())
|
|
).map((blocked) => (
|
|
<div key={blocked.staff_id} className="group bg-gradient-to-br from-white to-red-50 rounded-2xl border-2 border-red-200 hover:border-red-400 hover:shadow-xl transition-all duration-300 overflow-hidden">
|
|
<div className="p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-4 flex-1">
|
|
<div className="relative">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-red-500 to-red-700 rounded-2xl flex items-center justify-center text-white font-bold text-xl shadow-lg">
|
|
{blocked.staff_name?.charAt(0)}
|
|
</div>
|
|
<div className="absolute -top-1 -right-1 w-7 h-7 bg-red-600 rounded-full flex items-center justify-center shadow-md ring-2 ring-white">
|
|
<UserX className="w-4 h-4 text-white" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-bold text-lg text-slate-900 mb-2">{blocked.staff_name}</h4>
|
|
<div className="bg-red-100 border-l-4 border-red-500 p-3 rounded-lg mb-2">
|
|
<p className="text-xs font-semibold text-red-700 uppercase tracking-wide mb-1">Block Reason</p>
|
|
<p className="text-sm text-red-900 font-medium">{blocked.reason || 'No reason provided'}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-1.5 h-1.5 bg-red-500 rounded-full"></div>
|
|
Blocked on {new Date(blocked.blocked_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => removeFromBlocked(blocked.staff_id)}
|
|
className="border-2 border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 font-semibold"
|
|
>
|
|
<UserCheck className="w-4 h-4 mr-2" />
|
|
Unblock
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Staff Member</TableHead>
|
|
<TableHead>Reason</TableHead>
|
|
<TableHead>Blocked Date</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{userTeam.blocked_staff.filter(b =>
|
|
!blockedSearch ||
|
|
b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase())
|
|
).map((blocked) => (
|
|
<TableRow key={blocked.staff_id} className="hover:bg-red-50">
|
|
<TableCell>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-red-700 rounded-lg flex items-center justify-center text-white font-bold">
|
|
{blocked.staff_name?.charAt(0)}
|
|
</div>
|
|
<span className="font-semibold">{blocked.staff_name}</span>
|
|
<UserX className="w-4 h-4 text-red-600" />
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-red-900">{blocked.reason || 'No reason provided'}</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-xs text-slate-600">
|
|
{new Date(blocked.blocked_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => removeFromBlocked(blocked.staff_id)}
|
|
className="border-green-300 text-green-700 hover:bg-green-50"
|
|
>
|
|
<UserCheck className="w-4 h-4 mr-2" />
|
|
Unblock
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
) : (
|
|
<div className="text-center py-24 bg-gradient-to-br from-white to-slate-50 rounded-2xl border-2 border-dashed border-slate-200">
|
|
<div className="w-24 h-24 mx-auto mb-6 rounded-full bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
|
|
<UserCheck className="w-12 h-12 text-green-600" />
|
|
</div>
|
|
<h3 className="text-2xl font-bold text-slate-800 mb-3">No Blocked Staff</h3>
|
|
<p className="text-slate-600 mb-2 max-w-md mx-auto">
|
|
All staff members are currently eligible for assignments
|
|
</p>
|
|
<p className="text-sm text-slate-500">
|
|
Block staff members who should not be assigned to your events
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Invite Member Dialog */}
|
|
<Dialog open={showInviteMemberDialog} onOpenChange={setShowInviteMemberDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-bold">Invite team member</DialogTitle>
|
|
<p className="text-sm text-slate-500 mt-2">
|
|
Assign specific roles and permissions to control what each team member can access and manage.
|
|
</p>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>First name</Label>
|
|
<Input
|
|
value={inviteData.full_name.split(' ')[0] || ""}
|
|
onChange={(e) => {
|
|
const firstName = e.target.value;
|
|
const lastName = inviteData.full_name.split(' ').slice(1).join(' ');
|
|
setInviteData({ ...inviteData, full_name: `${firstName} ${lastName}`.trim() });
|
|
}}
|
|
placeholder=""
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Last name</Label>
|
|
<Input
|
|
value={inviteData.full_name.split(' ').slice(1).join(' ') || ""}
|
|
onChange={(e) => {
|
|
const firstName = inviteData.full_name.split(' ')[0] || "";
|
|
const lastName = e.target.value;
|
|
setInviteData({ ...inviteData, full_name: `${firstName} ${lastName}`.trim() });
|
|
}}
|
|
placeholder=""
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Email</Label>
|
|
<Input
|
|
type="email"
|
|
value={inviteData.email}
|
|
onChange={(e) => setInviteData({ ...inviteData, email: e.target.value })}
|
|
placeholder=""
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Access level</Label>
|
|
<Select value={inviteData.role} onValueChange={(value) => setInviteData({ ...inviteData, role: value })}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
<SelectItem value="manager">Manager</SelectItem>
|
|
<SelectItem value="member">Member</SelectItem>
|
|
<SelectItem value="viewer">Viewer</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>Hub Location</Label>
|
|
<Input
|
|
value={inviteData.hub}
|
|
onChange={(e) => setInviteData({ ...inviteData, hub: e.target.value })}
|
|
placeholder="e.g., BVG300"
|
|
list="existing-hubs"
|
|
/>
|
|
<datalist id="existing-hubs">
|
|
{teamHubs.map((hub) => {
|
|
const isRecent = new Date() - new Date(hub.created_date) < 24 * 60 * 60 * 1000;
|
|
return (
|
|
<option key={hub.id} value={hub.hub_name}>
|
|
{isRecent ? '✨ ' : ''}{hub.hub_name}
|
|
</option>
|
|
);
|
|
})}
|
|
</datalist>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
{preSelectedHub && <span className="text-[#0A39DF] font-semibold">✨ Pre-selected from hub creation • </span>}
|
|
{teamHubs.find(h => h.hub_name === inviteData.hub) ? '✓ Existing hub' : inviteData.hub ? '+ Will create new hub' : 'Type to search or create'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label>Department</Label>
|
|
<Input
|
|
value={inviteData.department}
|
|
onChange={(e) => setInviteData({ ...inviteData, department: e.target.value })}
|
|
placeholder="e.g., Catering FOH"
|
|
list="existing-departments"
|
|
/>
|
|
<datalist id="existing-departments">
|
|
{uniqueDepartments.map((dept) => (
|
|
<option key={dept} value={dept} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-6">
|
|
<Button variant="outline" onClick={() => setShowInviteMemberDialog(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleInviteMember}
|
|
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
|
disabled={!inviteData.email || !inviteData.full_name || inviteMemberMutation.isPending}
|
|
>
|
|
{inviteMemberMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Sending invitation...
|
|
</>
|
|
) : (
|
|
"Send invitation"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Department Dialog */}
|
|
<Dialog open={showDepartmentDialog} onOpenChange={setShowDepartmentDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingDepartment ? 'Edit Department' : 'Add New Department'}</DialogTitle>
|
|
<DialogDescription>
|
|
Manage the list of departments available for your team members.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>Department Name</Label>
|
|
<Input
|
|
value={newDepartment}
|
|
onChange={(e) => setNewDepartment(e.target.value)}
|
|
placeholder="e.g., Operations, Sales, HR"
|
|
className="mt-2"
|
|
/>
|
|
</div>
|
|
<div className="bg-slate-50 p-4 rounded-lg">
|
|
<h4 className="font-semibold text-sm mb-3">Current Departments:</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{uniqueDepartments.length > 0 ? (
|
|
uniqueDepartments.map(dept => (
|
|
<div key={dept} className="flex items-center gap-2 bg-white px-3 py-2 rounded-lg border border-slate-200 shadow-sm">
|
|
<span className="text-sm font-medium">{dept}</span>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 hover:bg-blue-50 hover:text-[#0A39DF]"
|
|
onClick={() => {
|
|
setEditingDepartment(dept);
|
|
setNewDepartment(dept);
|
|
}}
|
|
>
|
|
<Edit className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 hover:bg-red-50 hover:text-red-600"
|
|
onClick={() => handleDeleteDepartment(dept)}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-slate-500">No departments added yet. Add your first department above.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowDepartmentDialog(false);
|
|
setEditingDepartment(null);
|
|
setNewDepartment("");
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSaveDepartment}
|
|
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
|
disabled={!newDepartment.trim()}
|
|
>
|
|
{editingDepartment ? 'Update' : 'Add'} Department
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Member Dialog */}
|
|
<Dialog open={showEditMemberDialog} onOpenChange={setShowEditMemberDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Team Member</DialogTitle>
|
|
</DialogHeader>
|
|
{editingMember && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>Full Name *</Label>
|
|
<Input
|
|
value={editingMember.member_name}
|
|
onChange={(e) => setEditingMember({ ...editingMember, member_name: e.target.value })}
|
|
placeholder="John Doe"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Email *</Label>
|
|
<Input
|
|
type="email"
|
|
value={editingMember.email}
|
|
onChange={(e) => setEditingMember({ ...editingMember, email: e.target.value })}
|
|
placeholder="john@example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Phone</Label>
|
|
<Input
|
|
value={editingMember.phone || ""}
|
|
onChange={(e) => setEditingMember({ ...editingMember, phone: e.target.value })}
|
|
placeholder="+1 (555) 123-4567"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Title</Label>
|
|
<Input
|
|
value={editingMember.title || ""}
|
|
onChange={(e) => setEditingMember({ ...editingMember, title: e.target.value })}
|
|
placeholder="Manager"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Role</Label>
|
|
<Select value={editingMember.role} onValueChange={(value) => setEditingMember({ ...editingMember, role: value })}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
<SelectItem value="manager">Manager</SelectItem>
|
|
<SelectItem value="member">Member</SelectItem>
|
|
<SelectItem value="viewer">Viewer</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Department</Label>
|
|
<Select
|
|
value={editingMember.department || ""}
|
|
onValueChange={(value) => setEditingMember({ ...editingMember, department: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select department" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{uniqueDepartments.map(dept => (
|
|
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Hub Location</Label>
|
|
<Input
|
|
value={editingMember.hub || ""}
|
|
onChange={(e) => setEditingMember({ ...editingMember, hub: e.target.value })}
|
|
placeholder="e.g., BVG300"
|
|
list="existing-hubs-edit"
|
|
/>
|
|
<datalist id="existing-hubs-edit">
|
|
{teamHubs.map((hub) => (
|
|
<option key={hub.id} value={hub.hub_name} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowEditMemberDialog(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleUpdateMember}
|
|
className="bg-[#0A39DF]"
|
|
disabled={!editingMember?.member_name || !editingMember?.email || updateMemberMutation.isPending}
|
|
>
|
|
{updateMemberMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Updating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Edit className="w-4 h-4 mr-2" />
|
|
Update Member
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Hub Dialog */}
|
|
<Dialog open={showAddHubDialog} onOpenChange={setShowAddHubDialog}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader className="pb-6 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center">
|
|
<Building2 className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-2xl">Create New Hub</DialogTitle>
|
|
<DialogDescription className="text-base">Add a new location hub for your team members</DialogDescription>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-r-lg">
|
|
<p className="text-sm text-blue-900">
|
|
<strong>Quick Tip:</strong> When inviting members, they can type this hub name (e.g., "BVG300") and it will auto-suggest!
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-base font-semibold mb-2 flex items-center gap-2">
|
|
<Building2 className="w-4 h-4 text-blue-600" />
|
|
Hub Information *
|
|
</Label>
|
|
<Input
|
|
value={newHub.hub_name}
|
|
onChange={(e) => setNewHub({ ...newHub, hub_name: e.target.value })}
|
|
placeholder="e.g., BVG300, Main Office, Downtown Location"
|
|
className="text-lg h-12 border-2"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Label className="text-base font-semibold flex items-center gap-2">
|
|
<MapPin className="w-4 h-4 text-blue-600" />
|
|
Location Address
|
|
</Label>
|
|
<Input
|
|
ref={addressInputRef}
|
|
value={newHub.address}
|
|
onChange={(e) => setNewHub({ ...newHub, address: e.target.value })}
|
|
placeholder="Start typing address... (e.g., 300 Bayview Dr)"
|
|
className="h-11"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<Label className="text-base font-semibold flex items-center gap-2">
|
|
<UserCheck className="w-4 h-4 text-blue-600" />
|
|
Hub Manager *
|
|
</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
value={newHub.manager_name}
|
|
onChange={(e) => setNewHub({ ...newHub, manager_name: e.target.value })}
|
|
placeholder="Manager Name *"
|
|
className="h-11"
|
|
required
|
|
/>
|
|
<Input
|
|
value={newHub.manager_position}
|
|
onChange={(e) => setNewHub({ ...newHub, manager_position: e.target.value })}
|
|
placeholder="Position/Title *"
|
|
className="h-11"
|
|
required
|
|
/>
|
|
</div>
|
|
<Input
|
|
type="email"
|
|
value={newHub.manager_email}
|
|
onChange={(e) => setNewHub({ ...newHub, manager_email: e.target.value })}
|
|
placeholder="manager@example.com"
|
|
className="h-11"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="border-t pt-6">
|
|
<Button variant="outline" onClick={() => setShowAddHubDialog(false)} size="lg">Cancel</Button>
|
|
<Button
|
|
onClick={() => createHubMutation.mutate(newHub)}
|
|
size="lg"
|
|
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg"
|
|
disabled={!newHub.hub_name || !newHub.manager_name || !newHub.manager_position || createHubMutation.isPending}
|
|
>
|
|
{createHubMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
Creating Hub...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Building2 className="w-5 h-5 mr-2" />
|
|
Create Hub
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Hub Department Dialog */}
|
|
<Dialog open={showAddHubDepartmentDialog} onOpenChange={setShowAddHubDepartmentDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader className="pb-4 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
|
<Building2 className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-xl">Add Department</DialogTitle>
|
|
<DialogDescription>to {selectedHubForDept?.hub_name}</DialogDescription>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-4">
|
|
<div>
|
|
<Label className="text-base font-semibold mb-2 block">Department Name *</Label>
|
|
<Input
|
|
value={newHubDepartment.department_name}
|
|
onChange={(e) => setNewHubDepartment({ ...newHubDepartment, department_name: e.target.value })}
|
|
placeholder="e.g., Catering FOH, Catering BOH, Operations"
|
|
className="h-11 text-base"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-base font-semibold mb-2 block">Cost Center</Label>
|
|
<Input
|
|
value={newHubDepartment.cost_center}
|
|
onChange={(e) => setNewHubDepartment({ ...newHubDepartment, cost_center: e.target.value })}
|
|
placeholder="CC-12345"
|
|
className="h-11 font-mono"
|
|
/>
|
|
</div>
|
|
|
|
<div className="bg-amber-50 border-l-4 border-amber-400 p-4 rounded-r-lg">
|
|
<p className="text-sm text-amber-900">
|
|
<strong>Note:</strong> Team members can be assigned to this department when invited or during profile setup.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="border-t pt-4">
|
|
<Button variant="outline" size="lg" onClick={() => {
|
|
setShowAddHubDepartmentDialog(false);
|
|
setNewHubDepartment({ department_name: "", cost_center: "" });
|
|
}}>Cancel</Button>
|
|
<Button
|
|
size="lg"
|
|
onClick={async () => {
|
|
const updatedDepartments = [...(selectedHubForDept.departments || []), newHubDepartment];
|
|
await base44.entities.TeamHub.update(selectedHubForDept.id, {
|
|
departments: updatedDepartments
|
|
});
|
|
|
|
// Also add department to team's global department list
|
|
const teamDepartments = userTeam?.departments || [];
|
|
if (!teamDepartments.includes(newHubDepartment.department_name)) {
|
|
await base44.entities.Team.update(userTeam.id, {
|
|
departments: [...teamDepartments, newHubDepartment.department_name]
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });
|
|
}
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
|
|
setShowAddHubDepartmentDialog(false);
|
|
setNewHubDepartment({ department_name: "", cost_center: "" });
|
|
toast({ title: "✅ Department Added", description: `Department added to ${selectedHubForDept.hub_name}` });
|
|
}}
|
|
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white"
|
|
disabled={!newHubDepartment.department_name}
|
|
>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Add Department
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Favorite Staff Dialog */}
|
|
<Dialog open={showAddFavoriteDialog} onOpenChange={setShowAddFavoriteDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Favorite Staff</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="max-h-96 overflow-y-auto space-y-2">
|
|
{allStaff.filter(s => !(userTeam?.favorite_staff || []).some(f => f.staff_id === s.id)).map((staff) => (
|
|
<Card key={staff.id} className="cursor-pointer hover:bg-blue-50 transition-colors" onClick={() => addToFavorites(staff)}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="w-10 h-10">
|
|
<AvatarFallback className="bg-[#0A39DF] text-white">
|
|
{staff.employee_name?.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<p className="font-semibold">{staff.employee_name}</p>
|
|
<p className="text-xs text-slate-500">{staff.position}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowAddFavoriteDialog(false)}>Cancel</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Blocked Staff Dialog */}
|
|
<Dialog open={showAddBlockedDialog} onOpenChange={setShowAddBlockedDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Block Staff Member</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>Reason for blocking *</Label>
|
|
<Input
|
|
value={blockReason}
|
|
onChange={(e) => setBlockReason(e.target.value)}
|
|
placeholder="Performance issues, policy violation, etc."
|
|
/>
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto space-y-2">
|
|
{allStaff.filter(s => !(userTeam?.blocked_staff || []).some(b => b.staff_id === s.id)).map((staff) => (
|
|
<Card key={staff.id} className="cursor-pointer hover:bg-red-50 transition-colors" onClick={() => addToBlocked(staff)}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="w-10 h-10">
|
|
<AvatarFallback className="bg-slate-200 text-slate-700">
|
|
{staff.employee_name?.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<p className="font-semibold">{staff.employee_name}</p>
|
|
<p className="text-xs text-slate-500">{staff.position}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => {
|
|
setShowAddBlockedDialog(false);
|
|
setBlockReason("");
|
|
}}>Cancel</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |