Modified by www.SourceFiles.app
This commit is contained in:
@@ -0,0 +1,512 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Plus, Trash2, FileText, Pencil, EyeOff, Eye, AlertTriangle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Field = { label: string; type: "text" | "textarea" | "yesno" | "date" };
|
||||||
|
type FormRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
schema_json: unknown;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/forms")({
|
||||||
|
head: () => ({ meta: [{ title: "Forms — School Portal" }] }),
|
||||||
|
component: FormsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyField: Field = { label: "", type: "text" };
|
||||||
|
|
||||||
|
function FormsPage() {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [activeForm, setActiveForm] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<FormRow | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
// Admins see inactive forms too, otherwise deactivating one would hide it from
|
||||||
|
// the only people who could bring it back.
|
||||||
|
const { data: forms } = useQuery({
|
||||||
|
queryKey: ["forms", isAdmin],
|
||||||
|
queryFn: async () => {
|
||||||
|
let q = supabase.from("forms").select("*");
|
||||||
|
if (!isAdmin) q = q.eq("active", true);
|
||||||
|
return ((await q.order("created_at", { ascending: false })).data ?? []) as FormRow[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response counts drive the delete warning — form_responses cascades on
|
||||||
|
// delete, so removing a form destroys its submissions.
|
||||||
|
const { data: counts } = useQuery({
|
||||||
|
queryKey: ["form-response-counts"],
|
||||||
|
enabled: isAdmin,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("form_responses").select("form_id");
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
for (const r of data ?? []) map[r.form_id] = (map[r.form_id] ?? 0) + 1;
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["forms"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["form-response-counts"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActive = useMutation({
|
||||||
|
mutationFn: async ({ id, active }: { id: string; active: boolean }) => {
|
||||||
|
const { error } = await supabase.from("forms").update({ active }).eq("id", id);
|
||||||
|
if (error) throw error;
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
onSuccess: (active) => {
|
||||||
|
toast.success(active ? "Form activated" : "Form deactivated");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { error } = await supabase.from("forms").delete().eq("id", id);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Form deleted");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDelete = (f: FormRow) => {
|
||||||
|
const n = counts?.[f.id] ?? 0;
|
||||||
|
const msg =
|
||||||
|
n > 0
|
||||||
|
? `Delete "${f.title}"?\n\nThis also permanently deletes ${n} submitted response${n === 1 ? "" : "s"}. That cannot be undone.\n\nTo stop families seeing the form while keeping its responses, deactivate it instead.`
|
||||||
|
: `Delete "${f.title}"? This cannot be undone.`;
|
||||||
|
if (confirm(msg)) remove.mutate(f.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Forms</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Push a form to families; they complete it in the portal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button onClick={() => setCreating(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> New form
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{(forms ?? []).map((f) => {
|
||||||
|
const n = counts?.[f.id] ?? 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className={`bg-card border rounded-lg p-4 ${f.active ? "" : "opacity-70"}`}
|
||||||
|
>
|
||||||
|
<button onClick={() => setActiveForm(f.id)} className="text-left w-full">
|
||||||
|
<FileText className="h-5 w-5 text-primary mb-2" />
|
||||||
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
{f.title}
|
||||||
|
{!f.active && <Badge variant="secondary">Inactive</Badge>}
|
||||||
|
</div>
|
||||||
|
{f.description && (
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">{f.description}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
|
{(f.schema_json as Field[])?.length ?? 0} field(s)
|
||||||
|
{isAdmin && ` · ${n} response${n === 1 ? "" : "s"}`}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-1 mt-3 pt-3 border-t">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(f)}>
|
||||||
|
<Pencil className="h-4 w-4 mr-1" /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setActive.mutate({ id: f.id, active: !f.active })}
|
||||||
|
disabled={setActive.isPending}
|
||||||
|
>
|
||||||
|
{f.active ? (
|
||||||
|
<>
|
||||||
|
<EyeOff className="h-4 w-4 mr-1" /> Deactivate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="h-4 w-4 mr-1" /> Activate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => confirmDelete(f)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
title={n > 0 ? `Also deletes ${n} response(s)` : "Delete form"}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{forms?.length === 0 && <div className="text-sm text-muted-foreground">No forms yet.</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<FormEditor
|
||||||
|
onDone={() => {
|
||||||
|
setCreating(false);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<FormEditor
|
||||||
|
form={editing}
|
||||||
|
responseCount={counts?.[editing.id] ?? 0}
|
||||||
|
onDone={() => {
|
||||||
|
setEditing(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeForm && (
|
||||||
|
<FormFillDialog formId={activeForm} onClose={() => setActiveForm(null)} isAdmin={isAdmin} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and edit share one editor: the only differences are the initial values
|
||||||
|
// and whether we insert or update.
|
||||||
|
function FormEditor({
|
||||||
|
form,
|
||||||
|
responseCount = 0,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
form?: FormRow;
|
||||||
|
responseCount?: number;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isEdit = !!form;
|
||||||
|
const [title, setTitle] = useState(form?.title ?? "");
|
||||||
|
const [description, setDescription] = useState(form?.description ?? "");
|
||||||
|
const [active, setActive] = useState(form?.active ?? true);
|
||||||
|
const [fields, setFields] = useState<Field[]>(
|
||||||
|
(form?.schema_json as Field[]) ?? [{ label: "Full name", type: "text" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const clean = fields.filter((f) => f.label.trim() !== "");
|
||||||
|
if (clean.length === 0) throw new Error("Add at least one field with a label");
|
||||||
|
if (isEdit) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("forms")
|
||||||
|
.update({ title, description, schema_json: clean, active })
|
||||||
|
.eq("id", form!.id);
|
||||||
|
if (error) throw error;
|
||||||
|
} else {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("forms")
|
||||||
|
.insert({ title, description, schema_json: clean, active, created_by: user!.id });
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(isEdit ? "Form updated" : "Form created");
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setField = (i: number, patch: Partial<Field>) =>
|
||||||
|
setFields((fs) => fs.map((f, j) => (j === i ? { ...f, ...patch } : f)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && onDone()}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit form" : "Create form"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 max-h-[70vh] overflow-auto">
|
||||||
|
{/* Responses store answers keyed by field label, so a rename leaves the
|
||||||
|
old answers stranded under the old key. Say so rather than silently
|
||||||
|
breaking the response view. */}
|
||||||
|
{isEdit && responseCount > 0 && (
|
||||||
|
<div className="flex items-start gap-2 text-xs border rounded-md p-3">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
This form already has {responseCount} response{responseCount === 1 ? "" : "s"}.
|
||||||
|
Answers are stored under their field label, so renaming or removing a field won't
|
||||||
|
change what was already submitted — those answers stay under the old label. Adding
|
||||||
|
fields is always safe.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between max-w-sm">
|
||||||
|
<Label className="text-xs">Active — visible to families</Label>
|
||||||
|
<Switch checked={active} onCheckedChange={setActive} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Fields</Label>
|
||||||
|
<div className="space-y-2 mt-1">
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
value={f.label}
|
||||||
|
onChange={(e) => setField(i, { label: e.target.value })}
|
||||||
|
placeholder="Field label"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="border rounded-md px-2 text-sm"
|
||||||
|
value={f.type}
|
||||||
|
onChange={(e) => setField(i, { type: e.target.value as Field["type"] })}
|
||||||
|
>
|
||||||
|
<option value="text">Short text</option>
|
||||||
|
<option value="textarea">Long text</option>
|
||||||
|
<option value="yesno">Yes / No</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setFields(fields.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFields([...fields, { ...emptyField }])}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add field
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!title || save.isPending}
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
>
|
||||||
|
{save.isPending ? "Saving…" : isEdit ? "Save changes" : "Create form"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onDone}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFillDialog({
|
||||||
|
formId,
|
||||||
|
onClose,
|
||||||
|
isAdmin,
|
||||||
|
}: {
|
||||||
|
formId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}) {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isParent = roles.includes("parent");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [studentId, setStudentId] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: form } = useQuery({
|
||||||
|
queryKey: ["form", formId],
|
||||||
|
queryFn: async () => (await supabase.from("forms").select("*").eq("id", formId).single()).data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: myStudents } = useQuery({
|
||||||
|
queryKey: ["my-students", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("parent_students")
|
||||||
|
.select("student_id, students(id, first_name, last_name)")
|
||||||
|
.eq("parent_id", user!.id);
|
||||||
|
return (data ?? []).map(
|
||||||
|
(d) => d.students as { id: string; first_name: string; last_name: string },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: responses } = useQuery({
|
||||||
|
queryKey: ["form-responses", formId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("form_responses")
|
||||||
|
.select("*, students(first_name, last_name)")
|
||||||
|
.eq("form_id", formId)
|
||||||
|
.order("submitted_at", { ascending: false });
|
||||||
|
const ids = Array.from(new Set((data ?? []).map((r) => r.submitted_by)));
|
||||||
|
const { data: profs } = ids.length
|
||||||
|
? await supabase.from("profiles").select("id, full_name").in("id", ids)
|
||||||
|
: { data: [] };
|
||||||
|
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name]));
|
||||||
|
return (data ?? []).map((r) => ({ ...r, submitter_name: map[r.submitted_by] ?? "—" }));
|
||||||
|
},
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("form_responses").insert({
|
||||||
|
form_id: formId,
|
||||||
|
student_id: studentId || null,
|
||||||
|
submitted_by: user!.id,
|
||||||
|
data_json: values,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Submitted");
|
||||||
|
qc.invalidateQueries({ queryKey: ["form-responses", formId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["form-response-counts"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = (form?.schema_json ?? []) as Field[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{form?.title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{form?.description && <p className="text-sm text-muted-foreground">{form.description}</p>}
|
||||||
|
{form && !form.active && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This form is inactive — families cannot see or submit it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-2">
|
||||||
|
{isParent && myStudents && myStudents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>For student</Label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded-md p-2 text-sm"
|
||||||
|
value={studentId}
|
||||||
|
onChange={(e) => setStudentId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{myStudents.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.first_name} {s.last_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<Label>{f.label}</Label>
|
||||||
|
{f.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
value={values[f.label] ?? ""}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : f.type === "yesno" ? (
|
||||||
|
<select
|
||||||
|
className="w-full border rounded-md p-2 text-sm"
|
||||||
|
value={values[f.label] ?? ""}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={f.type === "date" ? "date" : "text"}
|
||||||
|
value={values[f.label] ?? ""}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button className="w-full" onClick={() => submit.mutate()} disabled={submit.isPending}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-6 border-t pt-4">
|
||||||
|
<h3 className="font-medium text-sm mb-2">Responses ({responses?.length ?? 0})</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(responses ?? []).map((r) => (
|
||||||
|
<div key={r.id} className="border rounded-md p-3 text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{(r.students as { first_name: string; last_name: string } | null)?.first_name ??
|
||||||
|
"—"}{" "}
|
||||||
|
{(r.students as { last_name?: string } | null)?.last_name ?? ""}
|
||||||
|
<span className="text-muted-foreground font-normal ml-2">
|
||||||
|
by {r.submitter_name} · {new Date(r.submitted_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs space-y-0.5">
|
||||||
|
{Object.entries(r.data_json as Record<string, string>).map(([k, v]) => (
|
||||||
|
<div key={k}>
|
||||||
|
<span className="text-muted-foreground">{k}:</span> {v}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{responses?.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No responses yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user