Files
Client_Fe/src/components/hexa/views/add-group-members-dialog.tsx
T
Dhananjaya99 a1b870fa8a first commit
2026-09-28 11:48:14 +05:30

305 lines
13 KiB
TypeScript

"use client"
import * as React from "react"
import { toast } from "sonner"
import { createMember, getMembers } from "@/lib/api/customers"
import { addSubscriptionMember, getSubscription } from "@/lib/api/subscriptions"
import { useMediaQuery } from "@/lib/use-media-query"
import { useMutation } from "@/lib/use-mutation"
import type { SubscriptionSummary } from "@/lib/models/subscription"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface AddGroupMembersDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
summary: SubscriptionSummary | null
/** Called after a member is added so the caller can refresh. When a member was actually
* attached (not the demo path), this also carries the freshly refetched subscription so
* the caller can update its own copy (e.g. groupSummary) — without this, a caller that
* reopens the dialog later would show a stale member count. */
onChanged: (freshSummary?: SubscriptionSummary) => void
/** Sandbox demo mode: mutate local state instead of calling the backend. */
demo?: boolean
/** Pre-computed eligible-members list for the "existing member" tab (parent owns the customer roster). */
demoEligibleOptions?: { label: string; value: string }[]
demoAttachExisting?: (subscriptionId: string, customerId: string) => void
demoCreateAndAttach?: (subscriptionId: string, values: typeof EMPTY_NEW) => void
}
const EMPTY_NEW = {
fullname: "",
userName: "",
email: "",
phoneNumber: "",
gender: "",
birthday: "",
}
function AddGroupMembersDialog({
open,
onOpenChange,
summary,
onChanged,
demo = false,
demoEligibleOptions,
demoAttachExisting,
demoCreateAndAttach,
}: AddGroupMembersDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const [existingOptions, setExistingOptions] = React.useState<{ label: string; value: string }[]>([])
const [selectedExisting, setSelectedExisting] = React.useState("")
const [newValues, setNewValues] = React.useState(EMPTY_NEW)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
// Cosmetic only ("Done" vs "Cancel") -- the actual count always comes straight from
// `summary`, which is kept in sync with the server after every add (see afterAdd below).
const [hasAdded, setHasAdded] = React.useState(false)
const memberCount = summary?.members.length ?? 0
const limit = summary?.memberLimit ?? 0
const atLimit = memberCount >= limit
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setSelectedExisting("")
setNewValues(EMPTY_NEW)
}
}
// The dialog now stays open across multiple adds (a Group plan's members are usually added
// one at a time right after creation), so the "existing member" list needs to re-sync
// whenever the live member count changes, not just once when the dialog first opens.
React.useEffect(() => {
if (!open) return
if (demo) {
setExistingOptions(demoEligibleOptions ?? [])
} else {
void loadEligible(summary)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, memberCount, demo, summary])
async function loadEligible(current: SubscriptionSummary | null) {
if (!current) return
try {
const members = await getMembers()
const alreadyIn = new Set(current.members.map((m) => m.customerId))
// Dedupe by customer; exclude those already in this subscription.
const seen = new Set<string>()
const options: { label: string; value: string }[] = []
for (const row of members) {
const id = row.fkCustomerId
if (alreadyIn.has(id) || seen.has(id) || !row.customer) continue
seen.add(id)
options.push({ label: row.customer.cusName ?? row.customer.email ?? "Member", value: id })
}
setExistingOptions(options)
} catch {
// Non-fatal; the create-new tab still works.
}
}
/** Re-fetches the subscription so `summary` (and the caller's own copy of it) reflects
* the member just added, then refreshes the eligible-members list against that truth.
* This is what keeps the "N/limit" count and the "full" gate correct -- a client-side
* counter can't be trusted since the caller may reopen this dialog from stale data. */
async function afterAdd() {
setSelectedExisting("")
setNewValues(EMPTY_NEW)
setHasAdded(true)
if (!summary) return
if (demo) {
onChanged()
return
}
try {
const fresh = await getSubscription(summary.subscriptionId)
onChanged(fresh)
void loadEligible(fresh)
} catch {
// Non-fatal; the count may lag until the dialog is reopened.
onChanged()
}
}
async function attachExisting() {
if (!summary || !selectedExisting || busy) return
if (demo) {
demoAttachExisting?.(summary.subscriptionId, selectedExisting)
toast.success("Member added to the plan")
setSelectedExisting("")
onChanged()
return
}
try {
await run(() => addSubscriptionMember(summary.subscriptionId, selectedExisting))
toast.success("Member added to the plan")
// Stay open instead of closing -- a Group plan usually needs several members added back
// to back, and the "plan is full" state below takes over once the limit is reached.
setSelectedExisting("")
onChanged()
} catch {
// run() already toasted the real error.
}
}
async function createAndAttach(e: React.FormEvent) {
e.preventDefault()
if (!summary || busy) return
const v = newValues
if (!v.fullname.trim() || !v.userName.trim() || !v.email.trim() || !v.phoneNumber.trim() || !v.gender) return
if (demo) {
demoCreateAndAttach?.(summary.subscriptionId, v)
toast.success("Member created and added to the plan")
setNewValues(EMPTY_NEW)
onChanged()
return
}
try {
await run(async () => {
const created = await createMember({
fullname: v.fullname.trim(),
userName: v.userName.trim(),
email: v.email.trim(),
mobileNumber: v.phoneNumber.trim(),
gender: v.gender,
birthday: v.birthday,
})
await addSubscriptionMember(summary.subscriptionId, created.customer.cusId)
})
toast.success("Member created and added to the plan")
// Stay open instead of closing -- same reasoning as attachExisting above.
setNewValues(EMPTY_NEW)
onChanged()
} catch {
// run() already toasted the real error.
}
}
function setNew<K extends keyof typeof EMPTY_NEW>(key: K, value: string) {
setNewValues((n) => ({ ...n, [key]: value }))
}
const Footer = isDesktop ? DialogFooter : SheetFooter
const footerClassName = isDesktop ? "-mx-4 -mt-3 -mb-4" : "flex-col-reverse"
const buttonWidthClassName = isDesktop ? undefined : "w-full"
const body = atLimit ? (
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-center text-sm text-muted-foreground">
This plan is full ({limit} members).
</p>
<Button type="button" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>Done</Button>
</div>
) : (
<Tabs defaultValue="existing">
<TabsList className="w-full">
<TabsTrigger value="existing" className="flex-1">Existing member</TabsTrigger>
<TabsTrigger value="new" className="flex-1">New member</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Select a member</Label>
<Select items={existingOptions} value={selectedExisting || null} onValueChange={(v) => setSelectedExisting(v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder={existingOptions.length ? "Choose a member" : "No eligible members"} /></SelectTrigger>
<SelectContent>{existingOptions.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>{hasAdded ? "Done" : "Cancel"}</Button>
<Button type="button" className={buttonWidthClassName} onClick={attachExisting} disabled={!selectedExisting || busy}>{busy && <Spinner className="size-4" />}Add to plan</Button>
</Footer>
</TabsContent>
<TabsContent value="new" className="mt-3">
<form onSubmit={createAndAttach} className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-fullname">Full Name <span className="text-destructive">*</span></Label><Input id="gm-fullname" value={newValues.fullname} onChange={(e) => setNew("fullname", e.target.value)} required /></div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-username">Username <span className="text-destructive">*</span></Label><Input id="gm-username" value={newValues.userName} onChange={(e) => setNew("userName", e.target.value)} required /></div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-phone">Phone <span className="text-destructive">*</span></Label><Input id="gm-phone" type="tel" value={newValues.phoneNumber} onChange={(e) => setNew("phoneNumber", e.target.value)} required /></div>
</div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-email">Email <span className="text-destructive">*</span></Label><Input id="gm-email" type="email" value={newValues.email} onChange={(e) => setNew("email", e.target.value)} required /></div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Gender <span className="text-destructive">*</span></Label>
<Select items={[{ label: "Male", value: "Male" }, { label: "Female", value: "Female" }, { label: "Other", value: "Other" }]} value={newValues.gender || null} onValueChange={(v) => setNew("gender", v ?? "")}>
<SelectTrigger className="w-full"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent><SelectItem value="Male">Male</SelectItem><SelectItem value="Female">Female</SelectItem><SelectItem value="Other">Other</SelectItem></SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5"><Label htmlFor="gm-birthday">Birthday</Label><Input id="gm-birthday" type="date" value={newValues.birthday} onChange={(e) => setNew("birthday", e.target.value)} max={new Date().toISOString().slice(0, 10)} /></div>
</div>
<Footer className={footerClassName}>
<Button type="button" variant="outline" className={buttonWidthClassName} onClick={() => onOpenChange(false)}>{hasAdded ? "Done" : "Cancel"}</Button>
<Button type="submit" className={buttonWidthClassName} disabled={busy}>{busy && <Spinner className="size-4" />}Create & add</Button>
</Footer>
</form>
</TabsContent>
</Tabs>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>Add group members</DialogTitle>
<DialogDescription>
{summary?.planName} · {memberCount}/{limit} members
</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}
return (
<Sheet open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<SheetContent
side="bottom"
className="max-h-[92vh] overflow-y-auto rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
>
<SheetHeader>
<SheetTitle>Add group members</SheetTitle>
<SheetDescription>
{summary?.planName} · {memberCount}/{limit} members
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-2">{body}</div>
</SheetContent>
</Sheet>
)
}
export { AddGroupMembersDialog }