Make survey fields editable and improve OCR option normalization

- Replace read-only badge chips with toggleable multi-select chips for
  messageTopics, nextStep, campusPreference, and howHeard on card detail page
- Add editable Switch toggles for all boolean fields (prayerForTeam,
  prayerConfidential, iSaidYesBookSent, ftGuestLetterSent)
- Change edits state to Record<string, unknown> to support array and
  boolean values alongside strings
- Update OCR prompts to normalize visitType variations (e.g., "I am a
  first or second time guest at Echo Life" -> "First/Second Time Guest")
- Update message topics list in OCR schema to match actual survey form
  options (Stress/Anxiety, Hearing God's Voice, Dealing With Doubt, etc.)

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-08 20:22:36 -05:00
parent cf418bd197
commit 13fb94fee9
3 changed files with 133 additions and 60 deletions

View file

@ -42,8 +42,25 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
const MESSAGE_TOPIC_OPTIONS = [
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
"Parenting", "Grief & Loss", "Forgiveness", "Finances", "Purpose/Calling",
"Prayer", "Healthy Boundaries", "Understanding The Bible", "Emotional Health",
"Sharing My Faith", "Decision Making", "Spiritual Disciplines", "Spiritual Gifts",
];
const NEXT_STEP_OPTIONS = ["Baptism", "Next Steps"];
const CAMPUS_OPTIONS = ["Beulah", "Pace/Milton", "Gulf Breeze", "Warrington"];
const HOW_HEARD_OPTIONS = [
"This is my church home", "Regular Attender", "Drove by",
"Social Media", "Google", "Personal Invite",
];
type CardData = {
id: string;
name: string | null;
@ -106,7 +123,7 @@ export default function CardDetailPage() {
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [reprocessing, setReprocessing] = React.useState(false);
const [edits, setEdits] = React.useState<Record<string, string>>({});
const [edits, setEdits] = React.useState<Record<string, unknown>>({});
const [showRawOcr, setShowRawOcr] = React.useState(false);
const [showActivity, setShowActivity] = React.useState(false);
const [activityLog, setActivityLog] = React.useState<ActivityEntry[]>([]);
@ -152,13 +169,25 @@ export default function CardDetailPage() {
}, [card?.ocrStatus, id]);
const getValue = (field: keyof CardData): string => {
if (field in edits) return edits[field];
if (field in edits) return String(edits[field] ?? "");
const val = card?.[field];
if (val === null || val === undefined) return "";
return String(val);
};
const setField = (field: string, value: string) => {
const getArrayValue = (field: keyof CardData): string[] => {
if (field in edits) return (edits[field] as string[]) ?? [];
const val = card?.[field];
if (Array.isArray(val)) return val;
return [];
};
const getBoolValue = (field: keyof CardData): boolean => {
if (field in edits) return Boolean(edits[field]);
return Boolean(card?.[field]);
};
const setField = (field: string, value: unknown) => {
setEdits((prev) => ({ ...prev, [field]: value }));
};
@ -411,11 +440,15 @@ export default function CardDetailPage() {
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Prayer Requests</Label>
<Textarea
value={getValue("prayerRequests") as string || ""}
value={getValue("prayerRequests") || ""}
onChange={(e) => setField("prayerRequests", e.target.value)}
rows={3}
/>
</div>
<div className="flex flex-wrap gap-6">
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} />
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", v)} />
</div>
</CardContent>
</Card>
@ -428,47 +461,31 @@ export default function CardDetailPage() {
</CardHeader>
<CardContent>
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Message Topics</Label>
<div className="flex flex-wrap gap-1.5">
{card.messageTopics && card.messageTopics.length > 0 ? card.messageTopics.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">{t}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Next Steps</Label>
<div className="flex flex-wrap gap-1.5">
{card.nextStep && card.nextStep.length > 0 ? card.nextStep.map((s) => (
<Badge key={s} variant="secondary" className="text-xs">{s}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<MultiSelectField
label="Message Topics"
value={getArrayValue("messageTopics")}
options={MESSAGE_TOPIC_OPTIONS}
onChange={(v) => setField("messageTopics", v)}
/>
<MultiSelectField
label="Next Steps"
value={getArrayValue("nextStep")}
options={NEXT_STEP_OPTIONS}
onChange={(v) => setField("nextStep", v)}
/>
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} />
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Campus Preference</Label>
<div className="flex flex-wrap gap-1.5">
{card.campusPreference && card.campusPreference.length > 0 ? card.campusPreference.map((c) => (
<Badge key={c} variant="secondary" className="text-xs">{c}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">How Heard</Label>
<div className="flex flex-wrap gap-1.5">
{card.howHeard && card.howHeard.length > 0 ? card.howHeard.map((h) => (
<Badge key={h} variant="secondary" className="text-xs">{h}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<MultiSelectField
label="Campus Preference"
value={getArrayValue("campusPreference")}
options={CAMPUS_OPTIONS}
onChange={(v) => setField("campusPreference", v)}
/>
<MultiSelectField
label="How Heard"
value={getArrayValue("howHeard")}
options={HOW_HEARD_OPTIONS}
onChange={(v) => setField("howHeard", v)}
/>
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} />
</div>
</CardContent>
@ -486,18 +503,8 @@ export default function CardDetailPage() {
<Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} />
<Field label="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} />
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} />
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">I Said Yes Book Sent</Label>
<Badge variant={card.iSaidYesBookSent ? "default" : "secondary"}>
{card.iSaidYesBookSent ? "Yes" : "No"}
</Badge>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">FT Guest Letter Sent</Label>
<Badge variant={card.ftGuestLetterSent ? "default" : "secondary"}>
{card.ftGuestLetterSent ? "Yes" : "No"}
</Badge>
</div>
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} />
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} />
</div>
{getValue("notes") && (
<>
@ -695,3 +702,69 @@ function SelectField({ label, value, options, onChange }: { label: string; value
</div>
);
}
function MultiSelectField({
label,
value,
options,
onChange,
}: {
label: string;
value: string[];
options: string[];
onChange: (v: string[]) => void;
}) {
const selected = new Set(value);
const toggle = (opt: string) => {
const next = new Set(selected);
if (next.has(opt)) next.delete(opt);
else next.add(opt);
onChange(Array.from(next));
};
return (
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">{label}</Label>
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isOn = selected.has(opt);
return (
<button
key={opt}
type="button"
onClick={() => toggle(opt)}
className={cn(
"inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors",
isOn
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20"
: "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)}
>
{opt}
</button>
);
})}
</div>
</div>
);
}
function BooleanField({
label,
value,
onChange,
}: {
label: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center gap-3">
<Switch checked={value} onCheckedChange={onChange} size="sm" />
<Label className="text-sm cursor-pointer" onClick={() => onChange(!value)}>
{label}
</Label>
</div>
);
}

View file

@ -42,7 +42,7 @@ const responseCardSchema = z.object({
dateOfBirth: z.string().nullable().describe("Date of birth as written"),
maritalStatus: z.string().nullable().describe("Married, Single, or Other"),
maritalStatusOther: z.string().nullable().describe("Value if Other is checked"),
visitType: z.string().nullable().describe("First/Second Time Guest or Update My Information"),
visitType: z.string().nullable().describe("Which checkbox is marked? Normalize to exactly: 'First/Second Time Guest' or 'Update My Information'. The card may say 'I am a first or second time guest at Echo Life' — map that to 'First/Second Time Guest'. Return null if neither is checked."),
cellPhone: z.string().nullable().describe("Cell phone number"),
homePhone: z.string().nullable().describe("Home phone number"),
email: z.string().nullable().describe("Email address"),
@ -59,7 +59,7 @@ const responseCardSchema = z.object({
const surveySchema = z.object({
messageTopics: z.array(z.string()).describe(
"ONLY items whose checkbox is physically marked (X, checkmark, or filled). Options: Stress, Marriage, Revival, Addiction, Parenting, Miracles, Forgiveness, Finances, My Identity, Conflict Resolution, The Holy Spirit, Understanding The Bible, Spiritual Warfare, Sharing My Faith, Anxiety, Heaven, Spiritual Gifts. Return empty array if none are marked."
"ONLY items whose checkbox is physically marked (X, checkmark, or filled). Options: Stress/Anxiety, Marriage, Hearing God's Voice, Dealing With Doubt, Parenting, Grief & Loss, Forgiveness, Finances, Purpose/Calling, Prayer, Healthy Boundaries, Understanding The Bible, Emotional Health, Sharing My Faith, Decision Making, Spiritual Disciplines, Spiritual Gifts. Return empty array if none are marked."
),
messageTopicsOther: z.string().nullable().describe("Value if Other is filled in"),
nextStep: z.array(z.string()).describe(

View file

@ -38,7 +38,7 @@ Return ONLY valid JSON with this exact structure (no markdown, no code fences):
"dateOfBirth": "string as written or null",
"maritalStatus": "Married" or "Single" or "Other" or null,
"maritalStatusOther": "string if Other is checked, else null",
"visitType": "First/Second Time Guest" or "Update My Information" or null,
"visitType": "First/Second Time Guest" or "Update My Information" or null (normalize: if card says 'I am a first or second time guest' use 'First/Second Time Guest'),
"cellPhone": "string or null",
"homePhone": "string or null",
"email": "string or null",
@ -71,7 +71,7 @@ Only include the one(s) whose box is physically marked.
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
{
"messageTopics": ["ONLY topics whose checkbox is marked, from: Stress, Marriage, Revival, Addiction, Parenting, Miracles, Forgiveness, Finances, My Identity, Conflict Resolution, The Holy Spirit, Understanding The Bible, Spiritual Warfare, Sharing My Faith, Anxiety, Heaven, Spiritual Gifts"],
"messageTopics": ["ONLY topics whose checkbox is marked, from: Stress/Anxiety, Marriage, Hearing God's Voice, Dealing With Doubt, Parenting, Grief & Loss, Forgiveness, Finances, Purpose/Calling, Prayer, Healthy Boundaries, Understanding The Bible, Emotional Health, Sharing My Faith, Decision Making, Spiritual Disciplines, Spiritual Gifts"],
"messageTopicsOther": "string if Other is filled in, else null",
"nextStep": ["ONLY items whose checkbox is marked, from: Baptism, Next Steps"],
"attendanceDuration": "Less than 6 months" or "6 Months - 1 Year" or "1-3 Years" or "4-6 Years" or "7+ Years" or null,