305 lines
13 KiB
TypeScript
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 }
|