feat(Makefile): patch Layout.jsx queryKey for local development feat(frontend-web): mock base44 client for local development with role switching feat(frontend-web): add event assignment modal with conflict detection and bulk assign feat(frontend-web): add client dashboard with key metrics and quick actions feat(frontend-web): add layout component with role-based navigation feat(frontend-web): update various pages to use "@/components" alias feat(frontend-web): update create event page with ai assistant toggle feat(frontend-web): update dashboard page with new components feat(frontend-web): update events page with quick assign popover feat(frontend-web): update invite vendor page with hover card feat(frontend-web): update messages page with conversation list and message thread feat(frontend-web): update operator dashboard page with new components feat(frontend-web): update partner management page with new components feat(frontend-web): update permissions page with new components feat(frontend-web): update procurement dashboard page with new components feat(frontend-web): update smart vendor onboarding page with new components feat(frontend-web): update staff directory page with new components feat(frontend-web): update teams page with new components feat(frontend-web): update user management page with new components feat(frontend-web): update vendor compliance page with new components feat(frontend-web): update main.jsx to include react query provider feat: add vendor marketplace page feat: add global import fix to prepare-export script feat: add patch-layout-query-key script to fix query key feat: update patch-base44-client script to use a more robust method
1274 lines
56 KiB
JavaScript
1274 lines
56 KiB
JavaScript
|
|
import React, { useState } 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 { Users, Plus, Search, Building2, MapPin, UserCheck, Mail, Edit, Loader2, Trash2, UserX, LayoutGrid, List as ListIcon, RefreshCw, Send, Filter } 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 [inviteData, setInviteData] = useState({
|
|
email: "",
|
|
full_name: "",
|
|
role: "member",
|
|
});
|
|
|
|
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 () => {
|
|
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.owner_id === user.id);
|
|
|
|
// ISOLATION VERIFICATION
|
|
if (team && team.owner_id !== 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.company_name || `${user.full_name}'s Team` || "My Team";
|
|
|
|
team = await base44.entities.Team.create({
|
|
team_name: teamName,
|
|
owner_id: user.id, // CRITICAL: Links team to THIS user only
|
|
owner_name: user.full_name || user.email,
|
|
owner_role: userRole, // Tracks which layer this team belongs to
|
|
email: user.email,
|
|
phone: user.phone || "",
|
|
total_members: 0,
|
|
active_members: 0,
|
|
total_hubs: 0,
|
|
favorite_staff_count: 0,
|
|
blocked_staff_count: 0,
|
|
departments: [], // Initialize with an empty array for departments
|
|
});
|
|
|
|
console.log(`✅ Team created successfully for ${userRole}: ${team.id}`);
|
|
}
|
|
|
|
// FINAL VERIFICATION: Ensure team belongs to current user
|
|
if (team) {
|
|
console.log(`🔒 Team loaded for ${userRole}: ${team.team_name} (Owner: ${team.owner_id})`);
|
|
|
|
// Double-check ownership
|
|
if (team.owner_id !== 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: [],
|
|
});
|
|
|
|
// 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);
|
|
const deactivatedMembers = teamMembers.filter(m => !m.is_active);
|
|
|
|
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)}`;
|
|
|
|
const invite = await base44.entities.TeamMemberInvite.create({
|
|
team_id: userTeam.id,
|
|
team_name: userTeam.team_name || "Team",
|
|
invite_code: inviteCode,
|
|
email: "test@example.com",
|
|
full_name: "Test User",
|
|
role: "member",
|
|
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.");
|
|
}
|
|
|
|
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,
|
|
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 || "Team",
|
|
to: data.email,
|
|
subject: `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;">🎉 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 ${data.full_name || 'there'},
|
|
</p>
|
|
|
|
<p style="color: #475569; font-size: 14px; line-height: 1.6; margin-top: 16px;">
|
|
<strong>${user?.full_name || user?.email}</strong> has invited you to join <strong>${userTeam.team_name || 'our team'}</strong> as a <strong>${data.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: #f1f5f9; padding: 16px; border-radius: 8px; margin: 24px 0; border-left: 4px solid #0A39DF;">
|
|
<h3 style="color: #1C323E; margin: 0 0 8px 0; font-size: 14px;">What to do next:</h3>
|
|
<ol style="color: #475569; margin: 0; padding-left: 20px; line-height: 1.8; font-size: 13px;">
|
|
<li>Click the button above to register</li>
|
|
<li>Create your account with this email (${data.email})</li>
|
|
<li>You'll be automatically added to the team</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div style="background: #fef3c7; padding: 16px; border-radius: 8px; margin-top: 24px; border: 1px solid #fbbf24;">
|
|
<p style="color: #92400e; font-size: 13px; margin: 0;">
|
|
<strong>⏰ Important:</strong> This invitation will expire in 7 days.
|
|
</p>
|
|
</div>
|
|
|
|
<p style="color: #64748b; font-size: 13px; margin-top: 32px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
|
|
Your invite code: <strong>${inviteCode}</strong><br>
|
|
Questions? Contact ${user?.email || 'the team admin'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
});
|
|
|
|
return { invite };
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
|
|
queryClient.invalidateQueries({ queryKey: ['team-invites', userTeam?.id] });
|
|
setShowInviteMemberDialog(false);
|
|
setInviteData({
|
|
email: "",
|
|
full_name: "",
|
|
role: "member",
|
|
});
|
|
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 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>
|
|
)}
|
|
{member.department && (
|
|
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
|
{member.department}
|
|
</Badge>
|
|
)}
|
|
</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={handleAddDepartment}
|
|
className="hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Departments
|
|
</Button>
|
|
{['admin', 'procurement', 'operator', 'vendor'].includes(userRole) && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => createTestInviteMutation.mutate()}
|
|
disabled={createTestInviteMutation.isPending || !userTeam?.id}
|
|
className="hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
|
|
>
|
|
{createTestInviteMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
"View Onboarding"
|
|
)}
|
|
</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, and Invitations */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="grid w-full grid-cols-3 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>
|
|
</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="space-y-3">
|
|
{filteredMembers(activeMembers).map((member) => (
|
|
<div
|
|
key={member.id}
|
|
className="flex items-center justify-between p-5 rounded-xl border-2 border-slate-200 hover:border-[#0A39DF] transition-all hover:shadow-lg bg-white"
|
|
>
|
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
<div className="w-14 h-14 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-lg flex-shrink-0">
|
|
{member.member_name?.split(' ').map(n => n[0]).join('') || '?'}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-bold text-[#1C323E] text-lg truncate">{member.member_name}</h4>
|
|
<p className="text-sm text-slate-600 capitalize">{member.role} {member.title && `• ${member.title}`}</p>
|
|
<p className="text-xs text-slate-500 mt-1 truncate">
|
|
{member.phone && `${member.phone} • `}{member.email}
|
|
</p>
|
|
{member.department && (
|
|
<Badge variant="outline" className="mt-2 text-xs bg-blue-50 text-blue-700 border-blue-200">
|
|
{member.department}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 flex-shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleEditMember(member)}
|
|
className="hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
|
|
>
|
|
<Edit className="w-4 h-4 mr-2" />
|
|
Edit
|
|
</Button>
|
|
<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-4 h-4 mr-2" />
|
|
Deactivate
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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="space-y-3">
|
|
{filteredMembers(deactivatedMembers).map((member) => (
|
|
<div
|
|
key={member.id}
|
|
className="flex items-center justify-between p-5 rounded-xl border-2 border-slate-200 bg-slate-50/50 opacity-70 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-slate-400 to-slate-600 rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-lg flex-shrink-0 relative">
|
|
{member.member_name?.split(' ').map(n => n[0]).join('') || '?'}
|
|
<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 truncate">{member.member_name}</h4>
|
|
<p className="text-sm text-slate-600 capitalize">{member.role} {member.title && `• ${member.title}`}</p>
|
|
<p className="text-xs text-slate-500 mt-1 truncate">
|
|
{member.phone && `${member.phone} • `}{member.email}
|
|
</p>
|
|
{member.department && (
|
|
<Badge variant="outline" className="mt-2 text-xs bg-blue-50 text-blue-700 border-blue-200">
|
|
{member.department}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 flex-shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleEditMember(member)}
|
|
className="hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
|
|
>
|
|
<Edit className="w-4 h-4 mr-2" />
|
|
Edit
|
|
</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-4 h-4 mr-2" />
|
|
Activate
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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>
|
|
</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>
|
|
<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>
|
|
)}
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|