Add First Time Guest Date and Salvation Date fields
- Add firstTimeGuestDate and salvationDate (DateTime?) to Prisma schema - Auto-compute dates during OCR: find previous Sunday from card createdAt when visitType indicates first/second time guest or nextStep includes Baptism - Add editable date inputs on card detail page in Workflow section - Add to Monday.com mappable fields in settings for column mapping - Add table columns (hidden by default) for both date fields - Handle full ISO datetime strings in Monday.com date parser - Apply same logic during reprocessing Made-with: Cursor
This commit is contained in:
parent
d6d209cf9d
commit
b2c29120cd
8 changed files with 99 additions and 5 deletions
|
|
@ -49,6 +49,8 @@ model ResponseCard {
|
||||||
planningCenter String?
|
planningCenter String?
|
||||||
iSaidYesBookSent Boolean @default(false)
|
iSaidYesBookSent Boolean @default(false)
|
||||||
ftGuestLetterSent Boolean @default(false)
|
ftGuestLetterSent Boolean @default(false)
|
||||||
|
firstTimeGuestDate DateTime?
|
||||||
|
salvationDate DateTime?
|
||||||
|
|
||||||
// Meta
|
// Meta
|
||||||
sourceFile String?
|
sourceFile String?
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ export async function PUT(
|
||||||
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.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
|
||||||
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
|
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.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;
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ type CardData = {
|
||||||
planningCenter: string | null;
|
planningCenter: string | null;
|
||||||
iSaidYesBookSent: boolean;
|
iSaidYesBookSent: boolean;
|
||||||
ftGuestLetterSent: boolean;
|
ftGuestLetterSent: boolean;
|
||||||
|
firstTimeGuestDate: string | null;
|
||||||
|
salvationDate: string | null;
|
||||||
ocrStatus: string;
|
ocrStatus: string;
|
||||||
reviewStatus: string;
|
reviewStatus: string;
|
||||||
ocrConfidence: number | null;
|
ocrConfidence: number | null;
|
||||||
|
|
@ -189,6 +191,17 @@ export default function CardDetailPage() {
|
||||||
return Boolean(card?.[field]);
|
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) => {
|
const setField = (field: string, value: unknown) => {
|
||||||
setEdits((prev) => ({ ...prev, [field]: value }));
|
setEdits((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
@ -505,6 +518,8 @@ export default function CardDetailPage() {
|
||||||
<Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} />
|
<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="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} />
|
||||||
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} />
|
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} />
|
||||||
|
<DateField label="First Time Guest Date" value={getDateValue("firstTimeGuestDate")} onChange={(v) => setField("firstTimeGuestDate", v || null)} />
|
||||||
|
<DateField label="Salvation Date" value={getDateValue("salvationDate")} onChange={(v) => setField("salvationDate", v || null)} />
|
||||||
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} />
|
<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)} />
|
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -800,6 +815,15 @@ function MultiSelectField({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
||||||
|
<Input type="date" value={value || ""} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BooleanField({
|
function BooleanField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ const MONDAY_MAPPABLE_FIELDS = [
|
||||||
{ field: "planningCenter", label: "Planning Center" },
|
{ field: "planningCenter", label: "Planning Center" },
|
||||||
{ field: "iSaidYesBookSent", label: "I Said Yes Book Sent" },
|
{ field: "iSaidYesBookSent", label: "I Said Yes Book Sent" },
|
||||||
{ field: "ftGuestLetterSent", label: "FT Guest Letter 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" },
|
{ field: "reviewStatus", label: "Review Status" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,8 @@ export type ResponseCard = {
|
||||||
planningCenter: string | null;
|
planningCenter: string | null;
|
||||||
iSaidYesBookSent: boolean;
|
iSaidYesBookSent: boolean;
|
||||||
ftGuestLetterSent: boolean;
|
ftGuestLetterSent: boolean;
|
||||||
|
firstTimeGuestDate: string | null;
|
||||||
|
salvationDate: string | null;
|
||||||
mondayItemId: string | null;
|
mondayItemId: string | null;
|
||||||
ocrStatus: string;
|
ocrStatus: string;
|
||||||
reviewStatus: string;
|
reviewStatus: string;
|
||||||
|
|
@ -447,6 +449,22 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
header: "FT Guest Letter",
|
header: "FT Guest Letter",
|
||||||
cell: ({ row }) => <BoolCell value={row.original.ftGuestLetterSent} />,
|
cell: ({ row }) => <BoolCell value={row.original.ftGuestLetterSent} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "firstTimeGuestDate",
|
||||||
|
header: "FT Guest Date",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const v = row.original.firstTimeGuestDate;
|
||||||
|
return <span className="truncate text-sm">{v ? new Date(v).toLocaleDateString() : "—"}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "salvationDate",
|
||||||
|
header: "Salvation Date",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const v = row.original.salvationDate;
|
||||||
|
return <span className="truncate text-sm">{v ? new Date(v).toLocaleDateString() : "—"}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mondayLinked",
|
id: "mondayLinked",
|
||||||
header: "Monday.com",
|
header: "Monday.com",
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }
|
||||||
{ id: "planningCenter", label: "Planning Center" },
|
{ id: "planningCenter", label: "Planning Center" },
|
||||||
{ id: "iSaidYesBookSent", label: "I Said Yes Book" },
|
{ id: "iSaidYesBookSent", label: "I Said Yes Book" },
|
||||||
{ id: "ftGuestLetterSent", label: "FT Guest Letter" },
|
{ id: "ftGuestLetterSent", label: "FT Guest Letter" },
|
||||||
|
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
|
||||||
|
{ id: "salvationDate", label: "Salvation Date" },
|
||||||
{ id: "mondayLinked", label: "Monday.com" },
|
{ id: "mondayLinked", label: "Monday.com" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -102,7 +104,7 @@ const DEFAULT_HIDDEN: string[] = [
|
||||||
"prayerRequests", "prayerForTeam", "prayerConfidential",
|
"prayerRequests", "prayerForTeam", "prayerConfidential",
|
||||||
"messageTopics", "nextStep", "campusPreference", "howHeard",
|
"messageTopics", "nextStep", "campusPreference", "howHeard",
|
||||||
"followUp", "notes", "serviceTime", "planningCenter",
|
"followUp", "notes", "serviceTime", "planningCenter",
|
||||||
"iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked",
|
"iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getDefaultColumnVisibility(): VisibilityState {
|
export function getDefaultColumnVisibility(): VisibilityState {
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,10 @@ function normalizeYear(y: string): string {
|
||||||
function parseToISO(dateStr: string): string | null {
|
function parseToISO(dateStr: string): string | null {
|
||||||
const s = dateStr.replace(/\s+/g, " ").trim();
|
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)
|
// 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})$/);
|
const numParts = s.match(/^(\d{1,2})\s*[/\-.]\s*(\d{1,2})\s*[/\-.]\s*(\d{2,4})$/);
|
||||||
if (numParts) {
|
if (numParts) {
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@ export async function processFile(
|
||||||
|
|
||||||
const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
|
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({
|
await prisma.responseCard.update({
|
||||||
where: { id: card.id },
|
where: { id: card.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -122,7 +126,7 @@ export async function processFile(
|
||||||
dateOfBirth: asString(responseData.dateOfBirth),
|
dateOfBirth: asString(responseData.dateOfBirth),
|
||||||
maritalStatus: asString(responseData.maritalStatus),
|
maritalStatus: asString(responseData.maritalStatus),
|
||||||
maritalStatusOther: asString(responseData.maritalStatusOther),
|
maritalStatusOther: asString(responseData.maritalStatusOther),
|
||||||
visitType: asString(responseData.visitType),
|
visitType,
|
||||||
cellPhone: asString(responseData.cellPhone),
|
cellPhone: asString(responseData.cellPhone),
|
||||||
homePhone: asString(responseData.homePhone),
|
homePhone: asString(responseData.homePhone),
|
||||||
email: asString(responseData.email),
|
email: asString(responseData.email),
|
||||||
|
|
@ -136,13 +140,15 @@ export async function processFile(
|
||||||
prayerConfidential: asBool(responseData.prayerConfidential),
|
prayerConfidential: asBool(responseData.prayerConfidential),
|
||||||
messageTopics: surveyData.messageTopics ?? [],
|
messageTopics: surveyData.messageTopics ?? [],
|
||||||
messageTopicsOther: asString(surveyData.messageTopicsOther),
|
messageTopicsOther: asString(surveyData.messageTopicsOther),
|
||||||
nextStep: surveyData.nextStep ?? [],
|
nextStep: nextStepArr,
|
||||||
attendanceDuration: asString(surveyData.attendanceDuration),
|
attendanceDuration: asString(surveyData.attendanceDuration),
|
||||||
campusPreference: surveyData.campusPreference ?? [],
|
campusPreference: surveyData.campusPreference ?? [],
|
||||||
campusPreferenceOther: asString(surveyData.campusPreferenceOther),
|
campusPreferenceOther: asString(surveyData.campusPreferenceOther),
|
||||||
howHeard: surveyData.howHeard ?? [],
|
howHeard: surveyData.howHeard ?? [],
|
||||||
howHeardOther: asString(surveyData.howHeardOther),
|
howHeardOther: asString(surveyData.howHeardOther),
|
||||||
serviceAttended: asString(surveyData.serviceAttended),
|
serviceAttended: asString(surveyData.serviceAttended),
|
||||||
|
firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null,
|
||||||
|
salvationDate: hasBaptism(nextStepArr) ? sunday : null,
|
||||||
ocrStatus: "complete",
|
ocrStatus: "complete",
|
||||||
ocrConfidence: Math.round(avgConfidence),
|
ocrConfidence: Math.round(avgConfidence),
|
||||||
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
|
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
|
||||||
|
|
@ -221,6 +227,10 @@ export async function reprocessCard(cardId: string): Promise<void> {
|
||||||
|
|
||||||
const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
|
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({
|
await prisma.responseCard.update({
|
||||||
where: { id: cardId },
|
where: { id: cardId },
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -229,7 +239,7 @@ export async function reprocessCard(cardId: string): Promise<void> {
|
||||||
dateOfBirth: asString(responseData.dateOfBirth),
|
dateOfBirth: asString(responseData.dateOfBirth),
|
||||||
maritalStatus: asString(responseData.maritalStatus),
|
maritalStatus: asString(responseData.maritalStatus),
|
||||||
maritalStatusOther: asString(responseData.maritalStatusOther),
|
maritalStatusOther: asString(responseData.maritalStatusOther),
|
||||||
visitType: asString(responseData.visitType),
|
visitType,
|
||||||
cellPhone: asString(responseData.cellPhone),
|
cellPhone: asString(responseData.cellPhone),
|
||||||
homePhone: asString(responseData.homePhone),
|
homePhone: asString(responseData.homePhone),
|
||||||
email: asString(responseData.email),
|
email: asString(responseData.email),
|
||||||
|
|
@ -243,13 +253,15 @@ export async function reprocessCard(cardId: string): Promise<void> {
|
||||||
prayerConfidential: asBool(responseData.prayerConfidential),
|
prayerConfidential: asBool(responseData.prayerConfidential),
|
||||||
messageTopics: surveyData.messageTopics ?? [],
|
messageTopics: surveyData.messageTopics ?? [],
|
||||||
messageTopicsOther: asString(surveyData.messageTopicsOther),
|
messageTopicsOther: asString(surveyData.messageTopicsOther),
|
||||||
nextStep: surveyData.nextStep ?? [],
|
nextStep: nextStepArr,
|
||||||
attendanceDuration: asString(surveyData.attendanceDuration),
|
attendanceDuration: asString(surveyData.attendanceDuration),
|
||||||
campusPreference: surveyData.campusPreference ?? [],
|
campusPreference: surveyData.campusPreference ?? [],
|
||||||
campusPreferenceOther: asString(surveyData.campusPreferenceOther),
|
campusPreferenceOther: asString(surveyData.campusPreferenceOther),
|
||||||
howHeard: surveyData.howHeard ?? [],
|
howHeard: surveyData.howHeard ?? [],
|
||||||
howHeardOther: asString(surveyData.howHeardOther),
|
howHeardOther: asString(surveyData.howHeardOther),
|
||||||
serviceAttended: asString(surveyData.serviceAttended),
|
serviceAttended: asString(surveyData.serviceAttended),
|
||||||
|
firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null,
|
||||||
|
salvationDate: hasBaptism(nextStepArr) ? sunday : null,
|
||||||
ocrStatus: "complete",
|
ocrStatus: "complete",
|
||||||
ocrConfidence: Math.round(avgConfidence),
|
ocrConfidence: Math.round(avgConfidence),
|
||||||
ocrError: null,
|
ocrError: null,
|
||||||
|
|
@ -301,3 +313,27 @@ function asString(v: unknown): string | null {
|
||||||
function asBool(v: unknown): boolean {
|
function asBool(v: unknown): boolean {
|
||||||
return v === true;
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue