feat: Initialize monorepo structure and comprehensive documentation
This commit establishes the new monorepo architecture for the KROW Workforce platform. Key changes include: - Reorganized project into `frontend-web`, `mobile-apps`, `firebase`, `scripts`, and `secrets` directories. - Updated `Makefile` to support the new monorepo layout and automate Base44 export integration. - Fixed `scripts/prepare-export.js` for ES module compatibility and global component import resolution. - Created and updated `CONTRIBUTING.md` for developer onboarding. - Restructured, renamed, and translated all `docs/` files for clarity and consistency. - Implemented an interactive internal launchpad with diagram viewing capabilities. - Configured base Firebase project files (`firebase.json`, security rules). - Updated `README.md` to reflect the new project structure and documentation overview.
This commit is contained in:
524
frontend-web/src/pages/Messages.jsx
Normal file
524
frontend-web/src/pages/Messages.jsx
Normal file
@@ -0,0 +1,524 @@
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user