"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() 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(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 ? (

This plan is full ({limit} members).

) : ( Existing member New member
setNew("fullname", e.target.value)} required />
setNew("userName", e.target.value)} required />
setNew("phoneNumber", e.target.value)} required />
setNew("email", e.target.value)} required />
setNew("birthday", e.target.value)} max={new Date().toISOString().slice(0, 10)} />
) if (isDesktop) { return ( Add group members {summary?.planName} · {memberCount}/{limit} members {body} ) } return ( Add group members {summary?.planName} · {memberCount}/{limit} members
{body}
) } export { AddGroupMembersDialog }