Files
Krow-workspace/frontend-web/src/pages/Messages.jsx
bwnyasse 80cd49deb5 feat(Makefile): install frontend dependencies on dev command
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
2025-11-13 14:56:31 -05:00

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>
);
}