feat(web): groups list + group detail pages with trigram search
This commit is contained in:
parent
7708dd671c
commit
6b1a9191ab
135
apps/web/src/app/accounts/[id]/groups/page.tsx
Normal file
135
apps/web/src/app/accounts/[id]/groups/page.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
SearchIcon,
|
||||||
|
UsersIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
Users2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { listGroupsForAccount } from "@/lib/queries";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ q?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GroupsListPage({ params, searchParams }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { q } = await searchParams;
|
||||||
|
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const data = await listGroupsForAccount(op.id, id, q);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { account, groups } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl mx-auto space-y-6">
|
||||||
|
{/* Back link */}
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={`/accounts/${account.id}` as any}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
{account.label}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Groups in {account.label}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="secondary" className="h-6 px-2.5 text-xs tabular-nums">
|
||||||
|
{groups.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refresh button — no-op placeholder, wired in Task 17 */}
|
||||||
|
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="shrink-0">
|
||||||
|
<RefreshCwIcon />
|
||||||
|
Refresh Groups
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<form method="GET" className="relative">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search groups…"
|
||||||
|
defaultValue={q ?? ""}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Group list */}
|
||||||
|
{groups.length > 0 ? (
|
||||||
|
<div className="divide-y divide-border rounded-xl ring-1 ring-foreground/10 overflow-hidden bg-card">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Link
|
||||||
|
key={group.id}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/groups/${group.id}` as any}
|
||||||
|
className="flex items-center justify-between gap-4 px-4 py-3.5 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<UsersIcon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-sm truncate">{group.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||||
|
{group.participantCount} member{group.participantCount !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
|
<Users2Icon className="size-10 text-muted-foreground/40" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{q ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium">No groups match “{q}”</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Try a different search term or clear the search to see all groups.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium">No groups synced yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use “Refresh Groups” to pull the latest groups from this WhatsApp account.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{q && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={`/accounts/${account.id}/groups` as any}>Clear search</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/web/src/app/groups/[id]/page.tsx
Normal file
130
apps/web/src/app/groups/[id]/page.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
UsersIcon,
|
||||||
|
SendIcon,
|
||||||
|
BellPlusIcon,
|
||||||
|
ClockIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { getGroup } from "@/lib/queries";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _sendTestStub(_formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
// wired in Task 18
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GroupDetailPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const data = await getGroup(op.id, id);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { group, account } = data;
|
||||||
|
|
||||||
|
const lastSynced = group.lastSyncedAt
|
||||||
|
? new Date(group.lastSyncedAt).toLocaleDateString("en-MY", {
|
||||||
|
timeZone: "Asia/Kuala_Lumpur",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: "Never";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
|
||||||
|
{/* Back link */}
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={`/accounts/${group.accountId}/groups` as any}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
Back to Groups
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{group.name}</h1>
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<UsersIcon className="size-3.5 shrink-0" />
|
||||||
|
Account: {account.label}
|
||||||
|
{" · "}
|
||||||
|
{group.participantCount} member{group.participantCount !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Test Message */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Send Test Message</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Send a one-off message to this group to verify the connection.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={_sendTestStub} className="space-y-3">
|
||||||
|
<Textarea
|
||||||
|
name="text"
|
||||||
|
placeholder="Type your test message…"
|
||||||
|
rows={3}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
<SendIcon />
|
||||||
|
Send Message
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Use in reminder */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<BellPlusIcon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Use in a Reminder</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Schedule recurring messages to this group
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm" className="shrink-0">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={`/reminders/new?groupId=${group.id}` as any}>
|
||||||
|
New Reminder
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Last synced */}
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<ClockIcon className="size-3.5 shrink-0" />
|
||||||
|
Last synced: {lastSynced}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -43,3 +43,44 @@ export async function getAccount(operatorId: string, accountId: string) {
|
|||||||
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listGroupsForAccount(operatorId: string, accountId: string, q?: string) {
|
||||||
|
const account = await getAccount(operatorId, accountId);
|
||||||
|
if (!account) return null;
|
||||||
|
const trimmed = (q ?? "").trim();
|
||||||
|
const rows = trimmed
|
||||||
|
? await db.execute(sql`
|
||||||
|
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||||
|
FROM whatsapp_groups
|
||||||
|
WHERE account_id = ${accountId} AND name % ${trimmed}
|
||||||
|
ORDER BY similarity(name, ${trimmed}) DESC
|
||||||
|
LIMIT 50
|
||||||
|
`)
|
||||||
|
: await db.execute(sql`
|
||||||
|
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||||
|
FROM whatsapp_groups
|
||||||
|
WHERE account_id = ${accountId}
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 200
|
||||||
|
`);
|
||||||
|
const groups = (rows.rows as Array<Record<string, unknown>>).map((r) => ({
|
||||||
|
id: r.id as string,
|
||||||
|
accountId: r.account_id as string,
|
||||||
|
waGroupJid: r.wa_group_jid as string,
|
||||||
|
name: r.name as string,
|
||||||
|
participantCount: Number(r.participant_count),
|
||||||
|
isArchived: r.is_archived as boolean,
|
||||||
|
lastSyncedAt: r.last_synced_at as Date,
|
||||||
|
}));
|
||||||
|
return { account, groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroup(operatorId: string, groupId: string) {
|
||||||
|
const group = await db.query.whatsappGroups.findFirst({
|
||||||
|
where: (g, { eq }) => eq(g.id, groupId),
|
||||||
|
});
|
||||||
|
if (!group) return null;
|
||||||
|
const account = await getAccount(operatorId, group.accountId);
|
||||||
|
if (!account) return null;
|
||||||
|
return { group, account };
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user