Add Monday.com board fields and expand column mapper

- Add followUp, notes, serviceTime, planningCenter, iSaidYesBookSent,
  ftGuestLetterSent fields to ResponseCard to match Monday.com board
- Expand column mapper from 8 fields to all 32 ResponseCard fields with
  friendly labels matching the Easter Survey spreadsheet columns
- Add Workflow & Tracking section on card detail page for the new fields
- Fix pre-existing Buffer type error in images API route

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-07 12:50:03 -05:00
parent 58f38d92ed
commit d58285785e
5 changed files with 102 additions and 5 deletions

View file

@ -42,6 +42,14 @@ model ResponseCard {
howHeardOther String? howHeardOther String?
serviceAttended String? serviceAttended String?
// Monday.com / Workflow fields
followUp String?
notes String?
serviceTime String?
planningCenter String?
iSaidYesBookSent Boolean @default(false)
ftGuestLetterSent Boolean @default(false)
// Meta // Meta
sourceFile String? sourceFile String?
frontImagePath String? frontImagePath String?

View file

@ -77,6 +77,10 @@ export async function PUT(
"campusPreferenceOther", "campusPreferenceOther",
"howHeardOther", "howHeardOther",
"serviceAttended", "serviceAttended",
"followUp",
"notes",
"serviceTime",
"planningCenter",
"ocrStatus", "ocrStatus",
"reviewStatus", "reviewStatus",
"ocrError", "ocrError",
@ -87,6 +91,8 @@ export async function PUT(
if (body.prayerForTeam != null) data.prayerForTeam = Boolean(body.prayerForTeam); if (body.prayerForTeam != null) data.prayerForTeam = Boolean(body.prayerForTeam);
if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential); if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential);
if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence); if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence);
if (body.messageTopics != null) data.messageTopics = body.messageTopics; if (body.messageTopics != null) data.messageTopics = body.messageTopics;
if (body.nextStep != null) data.nextStep = body.nextStep; if (body.nextStep != null) data.nextStep = body.nextStep;

View file

@ -27,7 +27,7 @@ export async function GET(
const buffer = await getBuffer(key); const buffer = await getBuffer(key);
return new NextResponse(buffer, { return new NextResponse(new Uint8Array(buffer), {
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Cache-Control": "private, max-age=3600", "Cache-Control": "private, max-age=3600",

View file

@ -15,6 +15,7 @@ import {
ScanLine, ScanLine,
User, User,
ClipboardList, ClipboardList,
ClipboardCheck,
Code, Code,
RefreshCw, RefreshCw,
Loader2, Loader2,
@ -71,6 +72,12 @@ type CardData = {
howHeard: string[] | null; howHeard: string[] | null;
howHeardOther: string | null; howHeardOther: string | null;
serviceAttended: string | null; serviceAttended: string | null;
followUp: string | null;
notes: string | null;
serviceTime: string | null;
planningCenter: string | null;
iSaidYesBookSent: boolean;
ftGuestLetterSent: boolean;
ocrStatus: string; ocrStatus: string;
reviewStatus: string; reviewStatus: string;
ocrConfidence: number | null; ocrConfidence: number | null;
@ -434,6 +441,47 @@ export default function CardDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card variant="glass" className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="size-4 text-primary" />
Workflow & Tracking
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<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>
</div>
{getValue("notes") && (
<>
<Separator className="my-4 opacity-50" />
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Notes</Label>
<Textarea
value={getValue("notes")}
onChange={(e) => setField("notes", e.target.value)}
rows={3}
/>
</div>
</>
)}
</CardContent>
</Card>
{card.rawOcrResponse && ( {card.rawOcrResponse && (
<Card variant="glass" className="xl:col-span-2"> <Card variant="glass" className="xl:col-span-2">
<CardHeader> <CardHeader>

View file

@ -58,6 +58,41 @@ import { Checkbox } from "@/components/ui/checkbox";
type MondayColumn = { id: string; title: string; type: string }; type MondayColumn = { id: string; title: string; type: string };
const MONDAY_MAPPABLE_FIELDS = [
{ field: "name", label: "Name" },
{ field: "gender", label: "Gender" },
{ field: "dateOfBirth", label: "Date of Birth" },
{ field: "maritalStatus", label: "Marital Status" },
{ field: "visitType", label: "Visit Type" },
{ field: "followUp", label: "Follow-Up" },
{ field: "cellPhone", label: "Cell Phone" },
{ field: "homePhone", label: "Home Phone" },
{ field: "email", label: "Email" },
{ field: "address", label: "Address" },
{ field: "aptNumber", label: "Apt #" },
{ field: "city", label: "City" },
{ field: "state", label: "State" },
{ field: "zip", label: "Zip" },
{ field: "prayerRequests", label: "Prayer Requests" },
{ field: "prayerForTeam", label: "For Prayer Team" },
{ field: "prayerConfidential", label: "Confidential" },
{ field: "messageTopics", label: "Message Topics" },
{ field: "messageTopicsOther", label: "Other - Topics" },
{ field: "nextStep", label: "Next Step" },
{ field: "attendanceDuration", label: "Attendance Duration" },
{ field: "campusPreference", label: "Campus Preference" },
{ field: "campusPreferenceOther", label: "Other Location" },
{ field: "howHeard", label: "How Did You Hear" },
{ field: "howHeardOther", label: "Other - How Heard" },
{ field: "serviceAttended", label: "A B C D" },
{ field: "serviceTime", label: "Service Time" },
{ field: "notes", label: "Notes" },
{ field: "planningCenter", label: "Planning Center" },
{ field: "iSaidYesBookSent", label: "I Said Yes Book Sent" },
{ field: "ftGuestLetterSent", label: "FT Guest Letter Sent" },
{ field: "reviewStatus", label: "Review Status" },
] as const;
type SettingsData = { type SettingsData = {
ollamaUrl: string; ollamaUrl: string;
model: string; model: string;
@ -878,10 +913,10 @@ export default function SettingsPage() {
{mondayColumns.length > 0 && ( {mondayColumns.length > 0 && (
<div className="rounded-xl border border-border/50 bg-muted/20 p-3 space-y-2"> <div className="rounded-xl border border-border/50 bg-muted/20 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground mb-2">Column Mapping</p> <p className="text-xs font-medium text-muted-foreground mb-2">Column Mapping</p>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2 max-h-[28rem] overflow-y-auto pr-1">
{["name", "email", "cellPhone", "gender", "visitType", "city", "state", "reviewStatus"].map((field) => ( {MONDAY_MAPPABLE_FIELDS.map(({ field, label }) => (
<div key={field} className="flex items-center gap-2"> <div key={field} className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-24 shrink-0 capitalize">{field}</span> <span className="text-xs text-muted-foreground w-36 shrink-0">{label}</span>
<Select <Select
value={(settings.mondayColumnMap || {})[field] || "__none__"} value={(settings.mondayColumnMap || {})[field] || "__none__"}
onValueChange={(v) => setColumnMapping(field, !v || v === "__none__" ? "" : v)} onValueChange={(v) => setColumnMapping(field, !v || v === "__none__" ? "" : v)}
@ -899,7 +934,7 @@ export default function SettingsPage() {
</div> </div>
))} ))}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-24 shrink-0">Files (images)</span> <span className="text-xs text-muted-foreground w-36 shrink-0">Files (images)</span>
<Select <Select
value={(settings.mondayColumnMap || {})._files || "__none__"} value={(settings.mondayColumnMap || {})._files || "__none__"}
onValueChange={(v) => setColumnMapping("_files", !v || v === "__none__" ? "" : v)} onValueChange={(v) => setColumnMapping("_files", !v || v === "__none__" ? "" : v)}