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