diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 86ea857..3940e1c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,8 @@ model ResponseCard { planningCenter String? iSaidYesBookSent Boolean @default(false) ftGuestLetterSent Boolean @default(false) + firstTimeGuestDate DateTime? + salvationDate DateTime? // Meta sourceFile String? diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts index 39f52fd..abd50ea 100644 --- a/src/app/api/cards/[id]/route.ts +++ b/src/app/api/cards/[id]/route.ts @@ -93,6 +93,12 @@ export async function PUT( 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); + + for (const dateField of ["firstTimeGuestDate", "salvationDate"] as const) { + if (body[dateField] !== undefined) { + data[dateField] = body[dateField] ? new Date(body[dateField]) : null; + } + } if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence); if (body.messageTopics != null) data.messageTopics = body.messageTopics; if (body.nextStep != null) data.nextStep = body.nextStep; diff --git a/src/app/cards/[id]/page.tsx b/src/app/cards/[id]/page.tsx index b0b89a7..39eed8b 100644 --- a/src/app/cards/[id]/page.tsx +++ b/src/app/cards/[id]/page.tsx @@ -97,6 +97,8 @@ type CardData = { planningCenter: string | null; iSaidYesBookSent: boolean; ftGuestLetterSent: boolean; + firstTimeGuestDate: string | null; + salvationDate: string | null; ocrStatus: string; reviewStatus: string; ocrConfidence: number | null; @@ -189,6 +191,17 @@ export default function CardDetailPage() { return Boolean(card?.[field]); }; + const getDateValue = (field: keyof CardData): string => { + if (field in edits) { + const v = edits[field]; + if (!v) return ""; + return String(v).slice(0, 10); + } + const val = card?.[field]; + if (!val) return ""; + return String(val).slice(0, 10); + }; + const setField = (field: string, value: unknown) => { setEdits((prev) => ({ ...prev, [field]: value })); }; @@ -505,6 +518,8 @@ export default function CardDetailPage() { setField("followUp", v)} /> setField("serviceTime", v)} /> setField("planningCenter", v)} /> + setField("firstTimeGuestDate", v || null)} /> + setField("salvationDate", v || null)} /> setField("iSaidYesBookSent", v)} /> setField("ftGuestLetterSent", v)} /> @@ -800,6 +815,15 @@ function MultiSelectField({ ); } +function DateField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { + return ( +
+ + onChange(e.target.value)} /> +
+ ); +} + function BooleanField({ label, value, diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 6400d97..1ddfd2f 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -91,6 +91,8 @@ const MONDAY_MAPPABLE_FIELDS = [ { field: "planningCenter", label: "Planning Center" }, { field: "iSaidYesBookSent", label: "I Said Yes Book Sent" }, { field: "ftGuestLetterSent", label: "FT Guest Letter Sent" }, + { field: "firstTimeGuestDate", label: "First Time Guest Date" }, + { field: "salvationDate", label: "Salvation Date" }, { field: "reviewStatus", label: "Review Status" }, ] as const; diff --git a/src/components/cards/columns.tsx b/src/components/cards/columns.tsx index 6935d1c..a538caa 100644 --- a/src/components/cards/columns.tsx +++ b/src/components/cards/columns.tsx @@ -110,6 +110,8 @@ export type ResponseCard = { planningCenter: string | null; iSaidYesBookSent: boolean; ftGuestLetterSent: boolean; + firstTimeGuestDate: string | null; + salvationDate: string | null; mondayItemId: string | null; ocrStatus: string; reviewStatus: string; @@ -447,6 +449,22 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ header: "FT Guest Letter", cell: ({ row }) => , }, + { + accessorKey: "firstTimeGuestDate", + header: "FT Guest Date", + cell: ({ row }) => { + const v = row.original.firstTimeGuestDate; + return {v ? new Date(v).toLocaleDateString() : "—"}; + }, + }, + { + accessorKey: "salvationDate", + header: "Salvation Date", + cell: ({ row }) => { + const v = row.original.salvationDate; + return {v ? new Date(v).toLocaleDateString() : "—"}; + }, + }, { id: "mondayLinked", header: "Monday.com", diff --git a/src/components/cards/data-table.tsx b/src/components/cards/data-table.tsx index 32e454d..52221ad 100644 --- a/src/components/cards/data-table.tsx +++ b/src/components/cards/data-table.tsx @@ -90,6 +90,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] } { id: "planningCenter", label: "Planning Center" }, { id: "iSaidYesBookSent", label: "I Said Yes Book" }, { id: "ftGuestLetterSent", label: "FT Guest Letter" }, + { id: "firstTimeGuestDate", label: "FT Guest Date" }, + { id: "salvationDate", label: "Salvation Date" }, { id: "mondayLinked", label: "Monday.com" }, ], }, @@ -102,7 +104,7 @@ const DEFAULT_HIDDEN: string[] = [ "prayerRequests", "prayerForTeam", "prayerConfidential", "messageTopics", "nextStep", "campusPreference", "howHeard", "followUp", "notes", "serviceTime", "planningCenter", - "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked", + "iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked", ]; export function getDefaultColumnVisibility(): VisibilityState { diff --git a/src/lib/monday.ts b/src/lib/monday.ts index b0d7d54..69d1dbb 100644 --- a/src/lib/monday.ts +++ b/src/lib/monday.ts @@ -249,6 +249,10 @@ function normalizeYear(y: string): string { function parseToISO(dateStr: string): string | null { const s = dateStr.replace(/\s+/g, " ").trim(); + // Full ISO datetime e.g. "2026-04-06T00:00:00.000Z" + const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})T/); + if (isoFull) return `${isoFull[1]}-${isoFull[2]}-${isoFull[3]}`; + // MM/DD/YYYY or DD/MM/YYYY (all-numeric) const numParts = s.match(/^(\d{1,2})\s*[/\-.]\s*(\d{1,2})\s*[/\-.]\s*(\d{2,4})$/); if (numParts) { diff --git a/src/lib/ocr.ts b/src/lib/ocr.ts index 423dbe5..7fde243 100644 --- a/src/lib/ocr.ts +++ b/src/lib/ocr.ts @@ -114,6 +114,10 @@ export async function processFile( const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0; + const visitType = asString(responseData.visitType); + const nextStepArr = surveyData.nextStep ?? []; + const sunday = getPreviousSunday(card.createdAt); + await prisma.responseCard.update({ where: { id: card.id }, data: { @@ -122,7 +126,7 @@ export async function processFile( dateOfBirth: asString(responseData.dateOfBirth), maritalStatus: asString(responseData.maritalStatus), maritalStatusOther: asString(responseData.maritalStatusOther), - visitType: asString(responseData.visitType), + visitType, cellPhone: asString(responseData.cellPhone), homePhone: asString(responseData.homePhone), email: asString(responseData.email), @@ -136,13 +140,15 @@ export async function processFile( prayerConfidential: asBool(responseData.prayerConfidential), messageTopics: surveyData.messageTopics ?? [], messageTopicsOther: asString(surveyData.messageTopicsOther), - nextStep: surveyData.nextStep ?? [], + nextStep: nextStepArr, attendanceDuration: asString(surveyData.attendanceDuration), campusPreference: surveyData.campusPreference ?? [], campusPreferenceOther: asString(surveyData.campusPreferenceOther), howHeard: surveyData.howHeard ?? [], howHeardOther: asString(surveyData.howHeardOther), serviceAttended: asString(surveyData.serviceAttended), + firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null, + salvationDate: hasBaptism(nextStepArr) ? sunday : null, ocrStatus: "complete", ocrConfidence: Math.round(avgConfidence), rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })), @@ -221,6 +227,10 @@ export async function reprocessCard(cardId: string): Promise { const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0; + const visitType = asString(responseData.visitType); + const nextStepArr = surveyData.nextStep ?? []; + const sunday = getPreviousSunday(card.createdAt); + await prisma.responseCard.update({ where: { id: cardId }, data: { @@ -229,7 +239,7 @@ export async function reprocessCard(cardId: string): Promise { dateOfBirth: asString(responseData.dateOfBirth), maritalStatus: asString(responseData.maritalStatus), maritalStatusOther: asString(responseData.maritalStatusOther), - visitType: asString(responseData.visitType), + visitType, cellPhone: asString(responseData.cellPhone), homePhone: asString(responseData.homePhone), email: asString(responseData.email), @@ -243,13 +253,15 @@ export async function reprocessCard(cardId: string): Promise { prayerConfidential: asBool(responseData.prayerConfidential), messageTopics: surveyData.messageTopics ?? [], messageTopicsOther: asString(surveyData.messageTopicsOther), - nextStep: surveyData.nextStep ?? [], + nextStep: nextStepArr, attendanceDuration: asString(surveyData.attendanceDuration), campusPreference: surveyData.campusPreference ?? [], campusPreferenceOther: asString(surveyData.campusPreferenceOther), howHeard: surveyData.howHeard ?? [], howHeardOther: asString(surveyData.howHeardOther), serviceAttended: asString(surveyData.serviceAttended), + firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null, + salvationDate: hasBaptism(nextStepArr) ? sunday : null, ocrStatus: "complete", ocrConfidence: Math.round(avgConfidence), ocrError: null, @@ -301,3 +313,27 @@ function asString(v: unknown): string | null { function asBool(v: unknown): boolean { return v === true; } + +function getPreviousSunday(from: Date): Date { + const d = new Date(from); + const day = d.getDay(); + const diff = day === 0 ? 0 : day; + d.setDate(d.getDate() - diff); + d.setHours(0, 0, 0, 0); + return d; +} + +function isFirstTimeGuest(visitType: string | null): boolean { + if (!visitType) return false; + const lower = visitType.toLowerCase(); + return lower.includes("first") || lower.includes("second") || lower.includes("guest"); +} + +function hasBaptism(nextStep: unknown): boolean { + if (!Array.isArray(nextStep)) return false; + return nextStep.some((s) => { + if (typeof s !== "string") return false; + const lower = s.toLowerCase(); + return lower.includes("baptism") || lower.includes("expressing my faith"); + }); +}