mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Messaging: whole-board from Direct New Message + always-visible delete
- Whole board where users actually look: the Direct "New Message" dialog now lists "Whole Board — <Association>" rows for staff (All/Board tabs). Clicking one opens the group-topic composer prefilled with that board's members (the composer is now reusable: hideTrigger + controlled open + initialSelected, wired through MessagesPage). - Delete discoverability: the trash control on Direct conversations and Topic threads is now always visible (subtle, bottom-right) instead of hover-only, which read as "no delete option." Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, PenSquare, Loader2, Trash2 } from "lucide-react";
|
||||
import { Search, PenSquare, Loader2, Trash2, Users } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -22,13 +22,15 @@ import {
|
||||
interface Props {
|
||||
selectedPartnerId: string | null;
|
||||
onSelectPartner: (id: string, name: string) => void;
|
||||
// Start a group "topic" message to a whole board (memberIds = the board's users).
|
||||
onMessageWholeBoard?: (memberIds: string[], label: string) => void;
|
||||
}
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
export default function ConversationList({ selectedPartnerId, onSelectPartner }: Props) {
|
||||
export default function ConversationList({ selectedPartnerId, onSelectPartner, onMessageWholeBoard }: Props) {
|
||||
const { user, isHomeowner, isRvBoatLot, isBoardMember, isStaff } = useAuth();
|
||||
const { conversations, loading } = useConversations();
|
||||
const { toast } = useToast();
|
||||
@@ -85,7 +87,7 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }:
|
||||
<PenSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<NewConversationDialog onSelectPartner={onSelectPartner} />
|
||||
<NewConversationDialog onSelectPartner={onSelectPartner} onMessageWholeBoard={onMessageWholeBoard} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -173,7 +175,7 @@ export default function ConversationList({ selectedPartnerId, onSelectPartner }:
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ id: conv.partner_id, name: conv.partner_name }); }}
|
||||
title="Delete conversation"
|
||||
className="absolute right-2 top-2 hidden group-hover:flex h-6 w-6 items-center justify-center rounded-md bg-background/80 text-muted-foreground hover:text-destructive"
|
||||
className="absolute right-2 bottom-2 flex h-7 w-7 items-center justify-center rounded-md bg-background/70 text-muted-foreground opacity-60 hover:opacity-100 hover:text-destructive hover:bg-background"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -215,10 +217,11 @@ type RecipientUser = {
|
||||
context?: string;
|
||||
};
|
||||
|
||||
function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: string, name: string) => void }) {
|
||||
function NewConversationDialog({ onSelectPartner, onMessageWholeBoard }: { onSelectPartner: (id: string, name: string) => void; onMessageWholeBoard?: (memberIds: string[], label: string) => void }) {
|
||||
const { user, isStaff, isBoardMember } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [users, setUsers] = useState<RecipientUser[]>([]);
|
||||
const [boardGroups, setBoardGroups] = useState<{ id: string; name: string; memberIds: string[] }[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tab, setTab] = useState<"all" | "homeowner" | "board" | "staff">("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -258,11 +261,22 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
|
||||
.neq("status", "archived"),
|
||||
(supabase as any)
|
||||
.from("board_members")
|
||||
.select("user_id, role, member_name, associations(name)")
|
||||
.select("user_id, role, member_name, association_id, associations(name)")
|
||||
.not("user_id", "is", null),
|
||||
supabase.from("user_roles").select("user_id, role").in("role", ["admin", "manager"]),
|
||||
]);
|
||||
|
||||
// Whole-board groups (one per association) so staff can message an entire board.
|
||||
const groups = new Map<string, { id: string; name: string; memberIds: string[] }>();
|
||||
for (const b of (boardRes.data ?? []) as any[]) {
|
||||
if (!b.user_id || b.user_id === user?.id || !b.association_id) continue;
|
||||
const g = groups.get(b.association_id)
|
||||
?? { id: b.association_id, name: b.associations?.name || "Association", memberIds: [] };
|
||||
if (!g.memberIds.includes(b.user_id)) g.memberIds.push(b.user_id);
|
||||
groups.set(b.association_id, g);
|
||||
}
|
||||
setBoardGroups(Array.from(groups.values()).filter((g) => g.memberIds.length).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
const userIds = new Set<string>();
|
||||
const recipients: RecipientUser[] = [];
|
||||
|
||||
@@ -359,6 +373,11 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
|
||||
|
||||
const showTabs = isStaff;
|
||||
|
||||
// Whole-board group rows (staff only) shown on the All / Board tabs.
|
||||
const visibleGroups = (onMessageWholeBoard && (tab === "all" || tab === "board"))
|
||||
? boardGroups.filter((g) => g.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -404,6 +423,31 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{visibleGroups.map((g) => (
|
||||
<button
|
||||
key={`board-${g.id}`}
|
||||
onClick={() => {
|
||||
onMessageWholeBoard?.(g.memberIds, g.name);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs bg-primary/15 text-primary">
|
||||
<Users className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium truncate">Whole Board — {g.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">Board</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{g.memberIds.length} member{g.memberIds.length === 1 ? "" : "s"} · starts a group topic
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.user_id}
|
||||
@@ -432,7 +476,7 @@ function NewConversationDialog({ onSelectPartner }: { onSelectPartner: (id: stri
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && !loading && (
|
||||
{filtered.length === 0 && visibleGroups.length === 0 && !loading && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No recipients found</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,20 @@ import { Plus, Loader2 } from "lucide-react";
|
||||
|
||||
type Recipient = { user_id: string; name: string; category: "Management" | "Board"; context?: string };
|
||||
|
||||
export default function NewTopicThreadDialog({ onCreated }: { onCreated: (threadId: string) => void }) {
|
||||
export default function NewTopicThreadDialog({
|
||||
onCreated, hideTrigger = false, open: openProp, onOpenChange, initialSelected,
|
||||
}: {
|
||||
onCreated: (threadId: string) => void;
|
||||
hideTrigger?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (o: boolean) => void;
|
||||
initialSelected?: string[];
|
||||
}) {
|
||||
const { user, isStaff } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openState, setOpenState] = useState(false);
|
||||
const open = openProp ?? openState;
|
||||
const setOpen = (o: boolean) => { onOpenChange ? onOpenChange(o) : setOpenState(o); };
|
||||
const [recipients, setRecipients] = useState<Recipient[]>([]);
|
||||
const [boardGroups, setBoardGroups] = useState<{ id: string; name: string; memberIds: string[] }[]>([]);
|
||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||
@@ -98,6 +108,12 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
|
||||
})();
|
||||
}, [open, user, isStaff]);
|
||||
|
||||
// When opened with a prefilled recipient set (e.g. "message whole board" from
|
||||
// the Direct dialog), preselect those members.
|
||||
useEffect(() => {
|
||||
if (open && initialSelected && initialSelected.length) setSelected(new Set(initialSelected));
|
||||
}, [open, initialSelected]);
|
||||
|
||||
const reset = () => { setTopic(""); setSubject(""); setBody(""); setSelected(new Set()); };
|
||||
|
||||
const toggle = (id: string) => {
|
||||
@@ -156,9 +172,11 @@ export default function NewTopicThreadDialog({ onCreated }: { onCreated: (thread
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) reset(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button>
|
||||
</DialogTrigger>
|
||||
{!hideTrigger && (
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> New Topic</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="max-w-lg overflow-hidden">
|
||||
<DialogHeader><DialogTitle>New Topic</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4 min-w-0">
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function TopicThreadList({
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ id: t.id, subject: t.subject }); }}
|
||||
title="Delete topic"
|
||||
className="absolute right-2 top-2 hidden group-hover:flex h-6 w-6 items-center justify-center rounded-md bg-background/80 text-muted-foreground hover:text-destructive"
|
||||
className="absolute right-2 bottom-2 flex h-7 w-7 items-center justify-center rounded-md bg-background/70 text-muted-foreground opacity-60 hover:opacity-100 hover:text-destructive hover:bg-background"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import ConversationList from "@/components/messaging/ConversationList";
|
||||
import ChatView from "@/components/messaging/ChatView";
|
||||
import TopicThreadList from "@/components/messaging/TopicThreadList";
|
||||
import TopicThreadView from "@/components/messaging/TopicThreadView";
|
||||
import NewTopicThreadDialog from "@/components/messaging/NewTopicThreadDialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
export default function MessagesPage() {
|
||||
@@ -12,6 +13,8 @@ export default function MessagesPage() {
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState<"direct" | "topics">("direct");
|
||||
const [composerOpen, setComposerOpen] = useState(false);
|
||||
const [boardPrefill, setBoardPrefill] = useState<string[] | undefined>(undefined);
|
||||
|
||||
// Deep-link from a topic notification (/dashboard/messages?thread=<id>).
|
||||
useEffect(() => {
|
||||
@@ -35,7 +38,15 @@ export default function MessagesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// "Message whole board" from the Direct dialog → open the group-topic composer
|
||||
// prefilled with that board's members.
|
||||
const handleMessageWholeBoard = (memberIds: string[]) => {
|
||||
setBoardPrefill(memberIds);
|
||||
setComposerOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "direct" | "topics")} className="space-y-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="direct">Direct</TabsTrigger>
|
||||
@@ -48,6 +59,7 @@ export default function MessagesPage() {
|
||||
<ConversationList
|
||||
selectedPartnerId={selectedPartnerId}
|
||||
onSelectPartner={handleSelectPartner}
|
||||
onMessageWholeBoard={handleMessageWholeBoard}
|
||||
/>
|
||||
</div>
|
||||
<ChatView partnerId={selectedPartnerId} partnerName={partnerName} />
|
||||
@@ -63,5 +75,14 @@ export default function MessagesPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<NewTopicThreadDialog
|
||||
hideTrigger
|
||||
open={composerOpen}
|
||||
onOpenChange={setComposerOpen}
|
||||
initialSelected={boardPrefill}
|
||||
onCreated={(id) => { setTab("topics"); handleSelectThread(id); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user