first commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"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 }
|
||||
Reference in New Issue
Block a user