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:
parent
cf418bd197
commit
13fb94fee9
3 changed files with 133 additions and 60 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue