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
525 lines
20 KiB
JavaScript
525 lines
20 KiB
JavaScript
|
|
import React, { useState } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { MessageSquare, Plus, Users } from "lucide-react";
|
|
import ConversationList from "@/components/messaging/ConversationList";
|
|
import MessageThread from "@/components/messaging/MessageThread";
|
|
import MessageInput from "@/components/messaging/MessageInput";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import PageHeader from "@/components/common/PageHeader";
|
|
|
|
export default function Messages() {
|
|
const [selectedConversation, setSelectedConversation] = useState(null);
|
|
const [activeTab, setActiveTab] = useState("all");
|
|
const [showNewConversation, setShowNewConversation] = useState(false);
|
|
const [conversationMode, setConversationMode] = useState("individual");
|
|
const [newConvData, setNewConvData] = useState({
|
|
type: "client-vendor",
|
|
subject: "",
|
|
participant_id: "",
|
|
participant_name: "",
|
|
participant_role: "client",
|
|
group_type: "all-active-staff",
|
|
event_id: ""
|
|
});
|
|
|
|
const { data: user } = useQuery({
|
|
queryKey: ['current-user'],
|
|
queryFn: () => base44.auth.me(),
|
|
});
|
|
|
|
const { data: staff } = useQuery({
|
|
queryKey: ['staff-list'],
|
|
queryFn: () => base44.entities.Staff.list(),
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: businesses } = useQuery({
|
|
queryKey: ['businesses-list'],
|
|
queryFn: () => base44.entities.Business.list(),
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: events } = useQuery({
|
|
queryKey: ['events-list'],
|
|
queryFn: () => base44.entities.Event.list('-date'),
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: conversations, refetch: refetchConversations } = useQuery({
|
|
queryKey: ['conversations'],
|
|
queryFn: () => base44.entities.Conversation.list('-last_message_at'),
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: messages, refetch: refetchMessages } = useQuery({
|
|
queryKey: ['messages', selectedConversation?.id],
|
|
queryFn: () => base44.entities.Message.filter({ conversation_id: selectedConversation?.id }),
|
|
initialData: [],
|
|
enabled: !!selectedConversation?.id
|
|
});
|
|
|
|
const filteredConversations = conversations.filter(conv => {
|
|
if (activeTab === "all") return conv.status === "active";
|
|
if (activeTab === "groups") return conv.is_group && conv.status === "active";
|
|
return conv.conversation_type === activeTab && conv.status === "active";
|
|
});
|
|
|
|
const getAvailableParticipants = () => {
|
|
const convType = newConvData.type;
|
|
|
|
if (convType === "client-vendor" || convType === "client-admin") {
|
|
return businesses.map(b => ({
|
|
id: b.id,
|
|
name: b.business_name,
|
|
role: "client",
|
|
contact: b.contact_name
|
|
}));
|
|
} else if (convType === "staff-client" || convType === "staff-admin") {
|
|
return staff.map(s => ({
|
|
id: s.id,
|
|
name: s.employee_name,
|
|
role: "staff",
|
|
position: s.position
|
|
}));
|
|
} else if (convType === "vendor-admin") {
|
|
return businesses.map(b => ({
|
|
id: b.id,
|
|
name: b.business_name,
|
|
role: "vendor",
|
|
contact: b.contact_name
|
|
}));
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const handleParticipantSelect = (participantId) => {
|
|
const participants = getAvailableParticipants();
|
|
const selected = participants.find(p => p.id === participantId);
|
|
|
|
if (selected) {
|
|
setNewConvData({
|
|
...newConvData,
|
|
participant_id: selected.id,
|
|
participant_name: selected.name,
|
|
participant_role: selected.role
|
|
});
|
|
}
|
|
};
|
|
|
|
const getGroupParticipants = () => {
|
|
if (newConvData.group_type === "all-active-staff") {
|
|
return staff.map(s => ({
|
|
id: s.id,
|
|
name: s.employee_name,
|
|
role: "staff"
|
|
}));
|
|
} else if (newConvData.group_type === "event-staff" && newConvData.event_id) {
|
|
const event = events.find(e => e.id === newConvData.event_id);
|
|
if (event && event.assigned_staff) {
|
|
return event.assigned_staff.map(s => ({
|
|
id: s.staff_id,
|
|
name: s.staff_name,
|
|
role: "staff"
|
|
}));
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const handleCreateConversation = async () => {
|
|
if (!newConvData.subject) return;
|
|
|
|
let participants = [];
|
|
let conversationType = "";
|
|
let groupName = "";
|
|
let isGroup = false;
|
|
let relatedTo = null;
|
|
let relatedType = null;
|
|
|
|
if (conversationMode === "individual") {
|
|
if (!newConvData.participant_id) return;
|
|
|
|
participants = [
|
|
{ id: user?.id, name: user?.full_name || user?.email, role: "admin" },
|
|
{
|
|
id: newConvData.participant_id,
|
|
name: newConvData.participant_name,
|
|
role: newConvData.participant_role
|
|
}
|
|
];
|
|
conversationType = newConvData.type;
|
|
} else {
|
|
const groupParticipants = getGroupParticipants();
|
|
if (groupParticipants.length === 0) return;
|
|
|
|
participants = [
|
|
{ id: user?.id, name: user?.full_name || user?.email, role: "admin" },
|
|
...groupParticipants
|
|
];
|
|
|
|
isGroup = true;
|
|
|
|
if (newConvData.group_type === "all-active-staff") {
|
|
conversationType = "group-staff";
|
|
groupName = "All Active Staff";
|
|
} else if (newConvData.group_type === "event-staff") {
|
|
conversationType = "group-event-staff";
|
|
const event = events.find(e => e.id === newConvData.event_id);
|
|
groupName = event ? `${event.event_name} Team` : "Event Team";
|
|
relatedTo = newConvData.event_id;
|
|
relatedType = "event";
|
|
}
|
|
}
|
|
|
|
const newConv = await base44.entities.Conversation.create({
|
|
participants,
|
|
conversation_type: conversationType,
|
|
is_group: isGroup,
|
|
group_name: groupName,
|
|
subject: newConvData.subject,
|
|
status: "active",
|
|
related_to: relatedTo,
|
|
related_type: relatedType
|
|
});
|
|
|
|
setShowNewConversation(false);
|
|
setConversationMode("individual");
|
|
setNewConvData({
|
|
type: "client-vendor",
|
|
subject: "",
|
|
participant_id: "",
|
|
participant_name: "",
|
|
participant_role: "client",
|
|
group_type: "all-active-staff",
|
|
event_id: ""
|
|
});
|
|
refetchConversations();
|
|
setSelectedConversation(newConv);
|
|
};
|
|
|
|
const availableParticipants = getAvailableParticipants();
|
|
const groupParticipants = conversationMode === "group" ? getGroupParticipants() : [];
|
|
|
|
return (
|
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
|
<div className="max-w-7xl mx-auto">
|
|
<PageHeader
|
|
title="Messages"
|
|
subtitle="Communicate with your team, vendors, and clients"
|
|
icon={MessageSquare}
|
|
>
|
|
<Button onClick={() => setShowNewConversation(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
New Conversation
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
|
|
<TabsList className="bg-white border border-slate-200">
|
|
<TabsTrigger value="all">All</TabsTrigger>
|
|
<TabsTrigger value="groups">
|
|
<Users className="w-4 h-4 mr-2" />
|
|
Groups
|
|
</TabsTrigger>
|
|
<TabsTrigger value="client-vendor">Client ↔ Vendor</TabsTrigger>
|
|
<TabsTrigger value="staff-client">Staff ↔ Client</TabsTrigger>
|
|
<TabsTrigger value="staff-admin">Staff ↔ Admin</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<Card className="lg:col-span-1 border-slate-200">
|
|
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
|
|
<CardTitle className="text-base">Conversations</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 max-h-[600px] overflow-y-auto">
|
|
<ConversationList
|
|
conversations={filteredConversations}
|
|
selectedId={selectedConversation?.id}
|
|
onSelect={setSelectedConversation}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="lg:col-span-2 border-slate-200">
|
|
{selectedConversation ? (
|
|
<>
|
|
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<CardTitle>{selectedConversation.subject}</CardTitle>
|
|
{selectedConversation.is_group && (
|
|
<Badge className="bg-purple-100 text-purple-700">
|
|
<Users className="w-3 h-3 mr-1" />
|
|
Group
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{selectedConversation.is_group ? (
|
|
<p className="text-sm text-slate-500 mt-1">
|
|
{selectedConversation.group_name} • {selectedConversation.participants?.length || 0} members
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-slate-500 mt-1">
|
|
{selectedConversation.participants?.map(p => p.name).join(', ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<MessageThread messages={messages} currentUserId={user?.id} />
|
|
<MessageInput
|
|
conversationId={selectedConversation.id}
|
|
currentUser={user}
|
|
onMessageSent={() => {
|
|
refetchMessages();
|
|
refetchConversations();
|
|
}}
|
|
/>
|
|
</CardContent>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full min-h-[400px]">
|
|
<div className="text-center">
|
|
<MessageSquare className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
|
<p className="text-slate-500">Select a conversation to start messaging</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={showNewConversation} onOpenChange={setShowNewConversation}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>New Conversation</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label className="mb-3 block font-semibold">Conversation Mode</Label>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setConversationMode("individual")}
|
|
className={`flex-1 flex items-center space-x-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
|
conversationMode === "individual"
|
|
? 'border-[#0A39DF] bg-blue-50'
|
|
: 'border-slate-200 bg-white hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
|
conversationMode === "individual" ? 'border-[#0A39DF]' : 'border-slate-300'
|
|
}`}>
|
|
{conversationMode === "individual" && (
|
|
<div className="w-2 h-2 rounded-full bg-[#0A39DF]" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 text-left">
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare className="w-4 h-4" />
|
|
<span className="font-semibold">Individual</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-1">One-on-one conversation</p>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setConversationMode("group")}
|
|
className={`flex-1 flex items-center space-x-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
|
conversationMode === "group"
|
|
? 'border-[#0A39DF] bg-blue-50'
|
|
: 'border-slate-200 bg-white hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
|
conversationMode === "group" ? 'border-[#0A39DF]' : 'border-slate-300'
|
|
}`}>
|
|
{conversationMode === "group" && (
|
|
<div className="w-2 h-2 rounded-full bg-[#0A39DF]" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 text-left">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-4 h-4" />
|
|
<span className="font-semibold">Group Chat</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-1">Message multiple people</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{conversationMode === "individual" ? (
|
|
<>
|
|
<div>
|
|
<Label>Conversation Type</Label>
|
|
<Select
|
|
value={newConvData.type}
|
|
onValueChange={(v) => setNewConvData({
|
|
...newConvData,
|
|
type: v,
|
|
participant_id: "",
|
|
participant_name: ""
|
|
})}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="client-vendor">Client ↔ Vendor</SelectItem>
|
|
<SelectItem value="staff-client">Staff ↔ Client</SelectItem>
|
|
<SelectItem value="staff-admin">Staff ↔ Admin</SelectItem>
|
|
<SelectItem value="vendor-admin">Vendor ↔ Admin</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Select {newConvData.type.includes('client') ? 'Client' : newConvData.type.includes('staff') ? 'Staff' : 'Vendor'}</Label>
|
|
<Select
|
|
value={newConvData.participant_id}
|
|
onValueChange={handleParticipantSelect}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={`Choose a ${newConvData.type.includes('client') ? 'client' : newConvData.type.includes('staff') ? 'staff member' : 'vendor'}`} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableParticipants.length > 0 ? (
|
|
availableParticipants.map((participant) => (
|
|
<SelectItem key={participant.id} value={participant.id}>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{participant.name}</span>
|
|
{participant.position && (
|
|
<span className="text-xs text-slate-500">{participant.position}</span>
|
|
)}
|
|
{participant.contact && (
|
|
<span className="text-xs text-slate-500">Contact: {participant.contact}</span>
|
|
)}
|
|
</div>
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value="none" disabled>
|
|
No {newConvData.type.includes('client') ? 'clients' : newConvData.type.includes('staff') ? 'staff' : 'vendors'} available
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<Label>Group Type</Label>
|
|
<Select
|
|
value={newConvData.group_type}
|
|
onValueChange={(v) => setNewConvData({...newConvData, group_type: v, event_id: ""})}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all-active-staff">
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">All Active Staff</span>
|
|
<span className="text-xs text-slate-500">{staff.length} members</span>
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="event-staff">
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">Event Staff Team</span>
|
|
<span className="text-xs text-slate-500">Select staff assigned to an event</span>
|
|
</div>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{newConvData.group_type === "event-staff" && (
|
|
<div>
|
|
<Label>Select Event</Label>
|
|
<Select
|
|
value={newConvData.event_id}
|
|
onValueChange={(v) => setNewConvData({...newConvData, event_id: v})}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choose an event" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{events.filter(e => e.assigned_staff && e.assigned_staff.length > 0).map((event) => (
|
|
<SelectItem key={event.id} value={event.id}>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{event.event_name}</span>
|
|
<span className="text-xs text-slate-500">
|
|
{event.assigned_staff?.length || 0} staff assigned
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{groupParticipants.length > 0 && (
|
|
<div className="bg-slate-50 p-4 rounded-lg">
|
|
<Label className="text-sm font-semibold mb-2 block">
|
|
Group Members ({groupParticipants.length})
|
|
</Label>
|
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
|
{groupParticipants.map((p) => (
|
|
<Badge key={p.id} variant="outline" className="bg-white">
|
|
{p.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div>
|
|
<Label>Subject</Label>
|
|
<Input
|
|
placeholder={conversationMode === "group" ? "e.g., Team Announcement" : "e.g., Order #12345 Discussion"}
|
|
value={newConvData.subject}
|
|
onChange={(e) => setNewConvData({...newConvData, subject: e.target.value})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowNewConversation(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleCreateConversation}
|
|
className="bg-[#0A39DF]"
|
|
disabled={
|
|
!newConvData.subject ||
|
|
(conversationMode === "individual" && !newConvData.participant_id) ||
|
|
(conversationMode === "group" && groupParticipants.length === 0)
|
|
}
|
|
>
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|