diff --git a/app/(pages)/admin/donations/layout.tsx b/app/(pages)/admin/donations/layout.tsx index 8f232b9..f6a39ac 100644 --- a/app/(pages)/admin/donations/layout.tsx +++ b/app/(pages)/admin/donations/layout.tsx @@ -10,6 +10,7 @@ export default function DonationsLayout({ children }: { children: React.ReactNod { name: "Donors List", reference: "/admin/donors" }, { name: "Add a Donation", reference: "/admin/donations/add" }, { name: "Add a Donor", reference: "/admin/donors/add" }, + { name: "Import", reference: "/admin/donors/import" }, ]} />
{children}
diff --git a/app/(pages)/admin/donors/import/page.tsx b/app/(pages)/admin/donors/import/page.tsx new file mode 100644 index 0000000..233ea23 --- /dev/null +++ b/app/(pages)/admin/donors/import/page.tsx @@ -0,0 +1,196 @@ +"use client"; +import React, { useState, useRef } from 'react'; +import convertExcelToCSV from '@/app/components/convertExcelToCSV'; + +export default function Import() { + const [selectedFile, setSelectedFile] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] || null; + if (file) { + setSelectedFile(file); + setStatusMessage(''); + } + }; + + // click area + const handleAreaClick = () => { + fileInputRef.current?.click(); + }; + + const handleUpload = async () => { + if (!selectedFile) { + setStatusMessage('Error: Please select a file first.'); + return; + } + + setIsUploading(true); + setStatusMessage('Uploading Excel file...'); + + try { + // Convert Excel -> CSV in the browser, then send CSV as 'csv' field + const csvText = await convertExcelToCSV(selectedFile); + const blob = new Blob([csvText], { type: 'text/csv' }); + const csvFileName = selectedFile.name.replace(/\.(xlsx|xls)$/i, '.csv'); + + const formData = new FormData(); + // backend import route expects 'csv' field + formData.append('csv', new File([blob], csvFileName, { type: 'text/csv' })); + + const apiUrl = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'}/admin/donations/import`; + + const response = await fetch(apiUrl, { + method: 'POST', + body: formData, + }); + + //error handling + if (!response.ok) { + let errorMessage = `Server error: ${response.status}`; + try { + // JSON error response + const errorData = await response.json(); + errorMessage = errorData?.message || "An unknown error occurred on the server."; + } catch (e) { + // non-JSON error response + errorMessage = await response.text(); + } + throw new Error(errorMessage); + } + + await response.json(); + setStatusMessage(`'${selectedFile.name}' was successfully processed and imported!`); + setSelectedFile(null); + + } catch (error: any) { + setStatusMessage(`Error: ${error.message}`); + console.error(error); + } finally { + setIsUploading(false); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + const file = event.dataTransfer.files?.[0] || null; + if (file) { + const allowedExtensions = ['.xlsx', '.xls']; + const fileNameParts = file.name.split('.'); + const fileExtension = `.${fileNameParts[fileNameParts.length - 1]}`.toLowerCase(); + + const allowedMimeTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ]; + + if (allowedExtensions.includes(fileExtension) || allowedMimeTypes.includes(file.type)) { + setSelectedFile(file); + setStatusMessage(''); + } else { + setStatusMessage('Error: Invalid file type. Please upload an Excel file (.xlsx or .xls).'); + setSelectedFile(null); + } + } + }; + + // upload icon svg + const UploadIcon = () => ( + + + + + + ); + + // expected database fields for the user's reference + const dbFields = [ + "Donor Type", "Donor First Name", "Donor Last Name", "Email Address", "Contact Number", "Mailing Address", + "Preferred Contact Method", "Company Name (if applicable)", "Donation Amount", "Donation Method", + "Donation Date", "Campaign/Event Name", "Donation Frequency", " Thank you/Follow Up Sent? ", + ]; + + return ( +
+
+ {/* Header */} +
+

Import Donations Data

+

Upload an Excel file (.xlsx, .xls) to import new records.

+
+ + {/* Hidden File Input */} + + + {/* Drag-and-Drop Area */} +
+
+ + {selectedFile ? ( +
+

{selectedFile.name}

+

({(selectedFile.size / 1024).toFixed(2)} KB)

+
+ ) : ( +

Click here or drag an Excel file to upload

+ )} +
+
+ + {/* Action Button */} +
+ +
+ + {/* Status Message */} + {statusMessage && ( +
+

{statusMessage}

+
+ )} + + {/* Database Fields Reference Section */} +
+

Expected Excel Columns

+
+

Ensure the first row of your Excel file contains columns matching these fields.

+
+ {dbFields.map(field =>

{field}

)} +
+
+
+
+
+ ); +} + diff --git a/app/(pages)/admin/donors/layout.tsx b/app/(pages)/admin/donors/layout.tsx index 2a3825f..70ccca8 100644 --- a/app/(pages)/admin/donors/layout.tsx +++ b/app/(pages)/admin/donors/layout.tsx @@ -10,6 +10,8 @@ export default function DonorsLayout({ children }: { children: React.ReactNode } { name: "Donors List", reference: "/admin/donors" }, { name: "Add a Donation", reference: "/admin/donations/add" }, { name: "Add a Donor", reference: "/admin/donors/add" }, + { name: "Import", reference: "/admin/donors/import" }, + ]} />
{children}
diff --git a/app/(pages)/admin/export/page.tsx b/app/(pages)/admin/export/page.tsx new file mode 100644 index 0000000..719b813 --- /dev/null +++ b/app/(pages)/admin/export/page.tsx @@ -0,0 +1,322 @@ +"use client"; +import React, { useState } from 'react'; + +export default function Export() { + const [isExporting, setIsExporting] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); + // Grants filters + const [showGrantsFilters, setShowGrantsFilters] = useState(false); + const [dueStart, setDueStart] = useState(""); + const [dueEnd, setDueEnd] = useState(""); + const [grantFund, setGrantFund] = useState(""); + const [grantMinAwarded, setGrantMinAwarded] = useState(""); + const [grantMaxAwarded, setGrantMaxAwarded] = useState(""); + const [grantStatus, setGrantStatus] = useState(""); + const [applicationType, setApplicationType] = useState(""); + const [grantorType, setGrantorType] = useState(""); + + // Donors (Donations export) filters + const [showDonorsFilters, setShowDonorsFilters] = useState(false); + const [donStart, setDonStart] = useState(""); + const [donEnd, setDonEnd] = useState(""); + const [donorType, setDonorType] = useState(""); + const [donorStatus, setDonorStatus] = useState(""); + const [commPref, setCommPref] = useState(""); + const [donFund, setDonFund] = useState(""); + const [minAmount, setMinAmount] = useState(""); + const [maxAmount, setMaxAmount] = useState(""); + const [paymentMethod, setPaymentMethod] = useState(""); + const [campaign, setCampaign] = useState(""); + const [ackSent, setAckSent] = useState(""); // '', 'true', 'false' + const [recurringFrequency, setRecurringFrequency] = useState(""); + + /** + * @param {string} dataType - The type of data to export (e.g., 'donations', 'donors'). + */ + const handleExport = async (dataType: string) => { + setIsExporting(dataType); + setStatusMessage(`Requesting ${dataType} data export...`); + + try { + // NOTE: API endpoint must be programmed to return a file, edit the endpoint here + // Use the admin API path + // donations export handler is used for donors export as well + const route = dataType === 'donors' ? 'donations' : dataType; + const baseUrl = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'}/admin/${route}/export`; + + // Append proposal due date range for grants + let apiUrl = baseUrl; + if (route === 'grants') { + const params = new URLSearchParams(); + if (dueStart) params.set('dueStart', dueStart); + if (dueEnd) params.set('dueEnd', dueEnd); + if (grantFund) params.set('fund', grantFund); + if (grantMinAwarded) params.set('minAmount', grantMinAwarded); + if (grantMaxAwarded) params.set('maxAmount', grantMaxAwarded); + if (grantStatus) params.set('status', grantStatus); + if (applicationType) params.set('applicationType', applicationType); + if (grantorType) params.set('grantorType', grantorType); + const qs = params.toString(); + apiUrl = qs ? `${baseUrl}?${qs}` : baseUrl; + } else if (route === 'donations') { + const params = new URLSearchParams(); + if (donStart) params.set('startDate', donStart); + if (donEnd) params.set('endDate', donEnd); + if (donorType) params.set('donorType', donorType); + if (donorStatus) params.set('donorStatus', donorStatus); + if (commPref) params.set('commPref', commPref); + if (donFund) params.set('fund', donFund); + if (minAmount) params.set('minAmount', minAmount); + if (maxAmount) params.set('maxAmount', maxAmount); + if (paymentMethod) params.set('paymentMethod', paymentMethod); + if (campaign) params.set('campaign', campaign); + if (ackSent) params.set('acknowledgementSent', ackSent); + if (recurringFrequency) params.set('recurringFrequency', recurringFrequency); + const qs = params.toString(); + apiUrl = qs ? `${baseUrl}?${qs}` : baseUrl; + } + const response = await fetch(apiUrl); + + if (!response.ok) { + throw new Error(`The server failed to generate the file. Status: ${response.status}`); + } + + const blob = await response.blob(); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + // keep the filename friendly: use 'donors' in the filename when dataType is donors + const filenameKey = dataType === 'donors' ? 'donors' : dataType; + a.download = `${filenameKey}_export_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + + setStatusMessage('Export completed successfully!'); + + } catch (error: any) { + setStatusMessage(`Error: ${error.message}`); + console.error(error); + } finally { + setIsExporting(null); + } + }; + + // svg icon: downloading + const DownloadIcon = () => ( + + + + + + ); + + return ( +
+
+
+

Export Data

+

Download database records as an Excel spreadsheet.

+
+ + {/* Export Options */} +
+ + {/* Export Grants Card */} +
+

Export Grants

+

Download a complete spreadsheet of all Grant records in the database.

+ {/* Filters (Proposal Due Date range) */} + + {showGrantsFilters && ( +
+
+
+ + setDueStart(e.target.value)} + className="w-full border rounded px-2 py-1" + /> +
+
+ + setDueEnd(e.target.value)} + className="w-full border rounded px-2 py-1" + /> +
+
+ + setGrantFund(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. Education"/> +
+
+
+ + setGrantMinAwarded(e.target.value)} className="w-full border rounded px-2 py-1"/> +
+
+ + setGrantMaxAwarded(e.target.value)} className="w-full border rounded px-2 py-1"/> +
+
+
+ + setGrantStatus(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. Open, Awarded"/> +
+
+ + setApplicationType(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. LOI"/> +
+
+ + setGrantorType(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. Foundation, Corporate"/> +
+
+
+ +
+
+ )} + +
+ + {/* Export Donors Card */} +
+

Export Donors

+

Download a complete spreadsheet of all donor profiles in the database.

+ {/* Donors Filters */} + + {showDonorsFilters && ( +
+
+
+ + setDonStart(e.target.value)} className="w-full border rounded px-2 py-1"/> +
+
+ + setDonEnd(e.target.value)} className="w-full border rounded px-2 py-1"/> +
+
+ + +
+
+ + setDonorStatus(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. Active"/> +
+
+ + +
+
+ + setDonFund(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. General Fund"/> +
+
+
+ + setMinAmount(e.target.value)} className="w-full border rounded px-2 py-1"/> +
+
+ + setMaxAmount(e.target.value)} className="w-full border rounded px-2 py-1"/> +
+
+
+ + setPaymentMethod(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. Check"/> +
+
+ + setCampaign(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. Gala 2025"/> +
+
+ + +
+
+ + setRecurringFrequency(e.target.value)} className="w-full border rounded px-2 py-1" placeholder="e.g. Monthly"/> +
+
+
+ +
+
+ )} + +
+
+ + {/* Status Message */} + {statusMessage && ( +
+

{statusMessage}

+
+ )} +
+
+ ); +} + diff --git a/app/(pages)/admin/grants/import/page.tsx b/app/(pages)/admin/grants/import/page.tsx new file mode 100644 index 0000000..b252083 --- /dev/null +++ b/app/(pages)/admin/grants/import/page.tsx @@ -0,0 +1,188 @@ +"use client"; +import React, { useState, useRef } from 'react'; +import convertExcelToCSV from '@/app/components/convertExcelToCSV'; + +export default function Import() { + const [selectedFile, setSelectedFile] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] || null; + if (file) { + setSelectedFile(file); + setStatusMessage(''); + } + }; + + // click area + const handleAreaClick = () => { + fileInputRef.current?.click(); + }; + + const handleUpload = async () => { + if (!selectedFile) { + setStatusMessage('Error: Please select a file first.'); + return; + } + + setIsUploading(true); + setStatusMessage('Uploading Excel file...'); + + try { + const csv = await convertExcelToCSV(selectedFile); + + const formData = new FormData(); + formData.append('csv', new Blob([csv], { type: 'text/csv' }), `${selectedFile.name.replace(/\..+$/, '')}.csv`); + + const apiUrl = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'}/admin/grants/import`; + const response = await fetch(apiUrl, { method: 'POST', body: formData }); + + //error handling + if (!response.ok) { + let errorMessage = `Server error: ${response.status}`; + try { + // JSON error response + const errorData = await response.json(); + errorMessage = errorData?.message || "An unknown error occurred on the server."; + } catch (e) { + // non-JSON error response + errorMessage = await response.text(); + } + throw new Error(errorMessage); + } + + await response.json(); + setStatusMessage(`'${selectedFile.name}' was successfully processed and imported!`); + setSelectedFile(null); + + } catch (error: any) { + setStatusMessage(`Error: ${error.message}`); + console.error(error); + } finally { + setIsUploading(false); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + const file = event.dataTransfer.files?.[0] || null; + if (file) { + const allowedExtensions = ['.xlsx', '.xls']; + const fileNameParts = file.name.split('.'); + const fileExtension = `.${fileNameParts[fileNameParts.length - 1]}`.toLowerCase(); + + const allowedMimeTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ]; + + if (allowedExtensions.includes(fileExtension) || allowedMimeTypes.includes(file.type)) { + setSelectedFile(file); + setStatusMessage(''); + } else { + setStatusMessage('Error: Invalid file type. Please upload an Excel file (.xlsx or .xls).'); + setSelectedFile(null); + } + } + }; + + // upload icon svg + const UploadIcon = () => ( + + + + + + ); + + // expected database fields for the user's reference + const dbFields = [ + "Assigned", "Quarter", "Funder", "Funding Area", "Kids-U Program", + "Contact Type", "LOI Due Date", "Grant Due Date", "Open-close dates", + "Funding Restrictions", "Written Amount", "Amount Awarded", "Notes", "Resources", "Link for grant import" + ]; + + return ( +
+
+ {/* Header */} +
+

Import Grant Data

+

Upload an Excel file (.xlsx, .xls) to import new records.

+
+ + {/* Hidden File Input */} + + + {/* Drag-and-Drop Area */} +
+
+ + {selectedFile ? ( +
+

{selectedFile.name}

+

({(selectedFile.size / 1024).toFixed(2)} KB)

+
+ ) : ( +

Click here or drag an Excel file to upload

+ )} +
+
+ + {/* Action Button */} +
+ +
+ + {/* Status Message */} + {statusMessage && ( +
+

{statusMessage}

+
+ )} + + {/* Database Fields Reference Section */} +
+

Expected Excel Columns

+
+

Ensure the first row of your Excel file contains columns matching these fields.

+
+ {dbFields.map(field =>

{field}

)} +
+
+
+
+
+ ); +} + diff --git a/app/(pages)/admin/grants/layout.tsx b/app/(pages)/admin/grants/layout.tsx index 6cd7303..6dca78e 100644 --- a/app/(pages)/admin/grants/layout.tsx +++ b/app/(pages)/admin/grants/layout.tsx @@ -9,6 +9,7 @@ export default function GrantsLayout({ children }: { children: React.ReactNode } { name: "Grants List", reference: "/admin/grants" }, { name: "Add a Grant", reference: "/admin/grants/add" }, { name: "Grantor List", reference: "/admin/grants/grantor" }, + { name: "Import", reference: "/admin/grants/import" }, ]} />
{children}
diff --git a/app/(pages)/admin/layout.tsx b/app/(pages)/admin/layout.tsx index bd6adb0..cf9b088 100644 --- a/app/(pages)/admin/layout.tsx +++ b/app/(pages)/admin/layout.tsx @@ -21,6 +21,7 @@ export default function VolunteerLayout({ children }: { children: React.ReactNod { name: "Donors", reference: "/admin/donors" }, { name: "Grants", reference: "/admin/grants" }, { name: "Volunteers", reference: "/admin/volunteer" }, + { name: "Export", reference: "/admin/export" }, ]} /> diff --git a/app/api/admin/donations/export/route.ts b/app/api/admin/donations/export/route.ts new file mode 100644 index 0000000..ee9e9d5 --- /dev/null +++ b/app/api/admin/donations/export/route.ts @@ -0,0 +1,159 @@ +import { NextResponse } from "next/server"; +import prisma from "@/app/utils/db"; +import * as xlsx from 'xlsx'; + +function fmtDate(d?: Date | null) { + if (!d) return ""; + try { + return new Intl.DateTimeFormat("en-US", { timeZone: "UTC" }).format(new Date(d)); + } catch { + return ""; + } +} + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const params = url.searchParams; + + // Parse filters + const startDateParam = params.get("startDate"); + const endDateParam = params.get("endDate"); + const donorType = params.get("donorType"); // e.g. 'Individual' or 'Organization' + const donorStatus = params.get("donorStatus"); + const commPref = params.get("commPref"); + const fund = params.get("fund"); + const minAmount = params.get("minAmount"); + const maxAmount = params.get("maxAmount"); + const paymentMethod = params.get("paymentMethod"); + const campaign = params.get("campaign"); + const acknowledgementSent = params.get("acknowledgementSent"); // 'true' | 'false' + const recurringFrequency = params.get("recurringFrequency"); + + const where: any = {}; + + if (startDateParam || endDateParam) { + const range: any = {}; + if (startDateParam) range.gte = new Date(`${startDateParam}T00:00:00.000Z`); + if (endDateParam) range.lte = new Date(`${endDateParam}T23:59:59.999Z`); + where.date = range; + } + + if (fund) { + where.fundDesignation = { contains: fund, mode: "insensitive" }; + } + + if (minAmount || maxAmount) { + where.amount = {}; + if (minAmount) where.amount.gte = Number(minAmount); + if (maxAmount) where.amount.lte = Number(maxAmount); + } + + // Donation-level filters + if (paymentMethod) where.paymentMethod = { equals: paymentMethod, mode: "insensitive" }; + if (campaign) where.campaign = { contains: campaign, mode: "insensitive" }; + if (acknowledgementSent === 'true' || acknowledgementSent === 'false') { + where.acknowledgementSent = acknowledgementSent === 'true'; + } + if (recurringFrequency) where.recurringFrequency = { equals: recurringFrequency, mode: "insensitive" }; + + // Donor-level composite filters + const donorWhere: any = {}; + if (donorType) donorWhere.type = donorType; + if (donorStatus) donorWhere.status = { equals: donorStatus, mode: "insensitive" }; + if (commPref) donorWhere.communicationPreference = { equals: commPref, mode: "insensitive" }; + if (Object.keys(donorWhere).length) { + where.donor = { is: donorWhere }; + } + + const donations = await prisma.donation.findMany({ + where, + include: { + donor: { + include: { + person: { include: { address: true } }, // load person.address + organization: { include: { address: true } }, + }, + }, + }, + orderBy: { date: "desc" }, + }); + + const headers = [ + "Donation ID", + "Donor Type", + "Donor First Name", + "Donor Last Name", + "Email Address", + "Contact Number", + "Address", + "Preferred Contact Method", + "Company Name (if applicable)", + "Donation Amount", + "Donation Method", + "Donation Date", + "Campaign/Event Name", + "Donation Frequency", + "Thank you/Follow Up Sent?" + ]; + + const rows: any[] = []; + rows.push(headers); + + for (const d of donations) { + const donor = d.donor; + const person = donor?.person ?? null; + const org = donor?.organization ?? null; + + // prefer person's address, fall back to org address, else empty + const addressLine1 = + person?.address?.addressLine1 ?? + org?.address?.addressLine1 ?? + ""; + + const row = [ + d.id, + donor?.type ?? "", + person?.firstName ?? "", + person?.lastName ?? "", + (person?.emailAddress ?? "") || (org?.emailAddress ?? ""), + person?.phoneNumber ?? "", + addressLine1, + donor?.communicationPreference ?? "", + org?.name ?? "", + d.amount, + d.paymentMethod ?? "", + fmtDate(d.date), + d.campaign ?? "", + d.recurringFrequency ?? "", + d.acknowledgementSent ? "Yes" : "No", + ]; + + rows.push(row); + } + + // Create worksheet and workbook using xlsx + const worksheet = xlsx.utils.aoa_to_sheet(rows); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Donations'); + + const wbOpts: xlsx.WritingOptions = { bookType: 'xlsx', type: 'array' }; + const wbOut: any = xlsx.write(workbook, wbOpts); // ArrayBuffer-like (Uint8Array) + + const filename = 'donations_export.xlsx'; + + // wbOut is an ArrayBuffer-like (Uint8Array); ensure we return an ArrayBuffer + const uint8 = typeof wbOut === 'object' && 'buffer' in wbOut ? wbOut : new Uint8Array(wbOut); + return new NextResponse(uint8.buffer, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (err) { + console.error("Export error:", err); + return NextResponse.json({ error: "Failed to export donations" }, { status: 500 }); + } +} + diff --git a/app/api/admin/donations/import/route.ts b/app/api/admin/donations/import/route.ts new file mode 100644 index 0000000..35c3f2f --- /dev/null +++ b/app/api/admin/donations/import/route.ts @@ -0,0 +1,347 @@ +import { NextResponse, NextRequest } from "next/server"; +import { parse } from "csv-parse/sync"; +import prisma from "@/app/utils/db"; + +type RecordType = { + [key: string]: string | number | null; +}; + +function parseDate(value: any): Date | null { + if (!value) return null; + const d = new Date(String(value)); + return isNaN(d.getTime()) ? null : d; +} + +function parseBoolean(value: any): boolean { + if (value === null || value === undefined) return false; + const str = String(value).toLowerCase().trim(); + const truthy = new Set(["true", "yes", "1", "y"]); + const falsy = new Set(["false", "no", "0", "n"]); + if (truthy.has(str)) return true; + if (falsy.has(str)) return false; + // Fallback: choose to return false or throw/log + return false; +} + +// Prefer Net Amount when present; fall back to Amount variants. +function pickAmountRaw(row: Record): string { + const get = (k: string) => { + const v = row[k]; + if (v === undefined || v === null) return null; + const s = String(v).trim(); + return s.length ? s : null; + }; + + const netKeys = [ + "Net Amount", + "net amount", + "Net amount", + "netAmount", + "NetAmount", + ]; + for (const k of netKeys) { + const v = get(k); + if (v) return v; + } + + const amountKeys = [ + "Amount", + "amount", + "Donation Amount", + "Amount (total)", + "Gift Amount", + ]; + for (const k of amountKeys) { + const v = get(k); + if (v) return v; + } + return "0"; +} + +// Split a full name into first and last. Supports "Last, First ..." and "First ... Last". +function splitFullName(value: any): { first: string | null; last: string | null } { + if (!value) return { first: null, last: null }; + const raw = String(value).trim().replace(/\s+/g, " "); + if (!raw) return { first: null, last: null }; + if (raw.includes(",")) { + const [lastPart, firstPartRaw] = raw.split(",", 2).map((s) => s.trim()); + const first = (firstPartRaw || "").split(" ")[0] || ""; + const last = lastPart || ""; + return { first: first || null, last: last || null }; + } + const parts = raw.split(" "); + if (parts.length === 1) return { first: parts[0] || null, last: null }; + const first = parts[0] || null; + const last = parts[parts.length - 1] || null; + return { first, last }; +} + + +export async function POST(req: NextRequest) { + try { + // Ensure multipart/form-data + const contentType = req.headers.get("content-type") || ""; + if (!contentType.startsWith("multipart/form-data")) { + return NextResponse.json( + { error: "Content-Type must be multipart/form-data" }, + { status: 415 } + ); + } + + const formData = await req.formData(); + const file = formData.get("csv") as File; + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); + } + let fileContent = await file.text(); + + // Check for Venmo format: has "Account Statement -" or "Account Activity" + const isVenmoFile = fileContent.includes("Account Statement -") || + fileContent.includes("Account Activity"); + + if (isVenmoFile) { + const lines = fileContent.split("\n"); + // Skip first TWO rows (header rows), keep the rest + // Row 0: "Account Statement - (@kids-u)" + // Row 1: "Account Activity" + // Row 2: Actual headers (Datetime, Type, Status, Note, From, To, Amount (total)) + fileContent = lines.slice(2).join("\n"); + } + + const records = parse(fileContent, { + columns: true, + skip_empty_lines: true, + relax_column_count: true, + }); + + // Normalize keys & values + const normalizedRecords: Record[] = records.map((record: Record) => { + const normalizedRecord: Record = {}; + for (const key in record) { + // Trim whitespace and strip a leading UTF-8 BOM if present (affects first header when exported from Excel) + const cleanedKey = key.trim().replace(/^\uFEFF/, ""); + const value = record[key]; + const cleanedValue = typeof value === "string" ? value.replace(/^\uFEFF/, "").trim() : value; + normalizedRecord[cleanedKey] = cleanedValue; + } + return normalizedRecord; + }); + + const summary = { + peopleCreated: 0, + organizationsCreated: 0, + donorsCreated: 0, + donationsCreated: 0, + errors: [] as string[], + }; + + for (let idx = 0; idx < normalizedRecords.length; idx++) { + const row = normalizedRecords[idx]; + try { + // Heuristics for donor info + let personFirst = row["Donor First Name"] || row["firstName"] || row["First"] || row["First Name"] || null; + let personLast = row["Donor Last Name"] || row["lastName"] || row["Last"] || row["Last Name"] || null; + // If a consolidated "From" column exists, use it to fill missing first/last + const fromFullName = row["From"] || row["from"] || null; + if ((!personFirst || !personLast) && fromFullName) { + const parsed = splitFullName(fromFullName); + if (!personFirst && parsed.first) personFirst = parsed.first; + if (!personLast && parsed.last) personLast = parsed.last; + } + const email = row["Email"] || row["Email Address"] || row["email"] || row["Donor Email"] || null; + const phone = row["Contact Number"] || row["Phone Number"] || row["phone"] || row["Phone"] || null; + const preferredContact = row["Preferred Contact Method"] || row["Preferred Contact"] || row["Contact Method"] || row["preferredContactMethod"] || row["contactMethod"] || null; + const mailingAddress = row["Mailing Address"] || row["Address"] || row["address"] || row["Street Address"] || null; + + // Add default donor type if missing (assume Individual) + if (!row["Donor Type"] && !row["Type"] && !row["type"]) { + row["Donor Type"] = "Individual"; + } + + // Normalize type values + let type = row["Donor Type"] || row["Type"] || row["type"] || "Individual"; + if (type != "Individual" && type != "individual" && type != "Corporate" && type != "corporate" && type != "In-Kind" && type != "In-kind" && type != "in-kind" && type != "In Kind" && type != "In kind" && type != "in kind") { + type = "Individual"; + } + + // Organization fields + const orgName = row["Organization"] || row["Organization Name"] || row["Company Name (if applicable)"] || row["Company"] || null; + const orgEmail = email; + + // Donation fields + const amountRaw = pickAmountRaw(row); + const amount = Number(String(amountRaw).replace(/[^0-9.-]+/g, "")) || 0; + const date = parseDate(row["Date"] || row["Donation Date"] || row["date"] || row["Datetime"]); + const fund = row["Fund"] || row["Kids-U Program"] || row["fund"] || row["Fund Designation"] || null; + const paymentMethod = row["Donation Method"] || row["paymentMethod"] || row["Payment Method"] || row["Payment Type"] || null; + const isAnonymous = parseBoolean(row["Anonymous"] || row["anonymous"]); + const campaign = row["Campaign/Event Name"] || row["campaign"] || row["Campaign"] || row["Event"] || null; + const recurringFrequency = row["Donation Frequency"] || row["recurringFrequency"] || row["Frequency"] || row["Recurring"] || null; + const acknowledgementSent = parseBoolean(row["Thank you/Follow Up Sent?"] || row["acknowledgementSent"] || row["Acknowledged"]); + const receiptSent = parseBoolean(row["Receipt Sent"] || row["receiptSent"] || row["Receipt"]); + const receiptNumber = row["Receipt Number"] || row["receiptNumber"] || row["Receipt ID"] || null; + + let donorId: string | null = null; + + + + // Individual donor logic + if (type == "Individual" || type == "individual") { + let person = null; + if (email) { + person = await prisma.person.findUnique({ where: { emailAddress: String(email) } }).catch(() => null); + } + if (!person && personFirst && personLast) { + person = await prisma.person.findFirst({ where: { firstName: String(personFirst), lastName: String(personLast) } }).catch(() => null); + } + if (!person && personFirst && personLast) { + person = await prisma.person.create({ + data: { + firstName: String(personFirst), + lastName: String(personLast), + emailAddress: email ? String(email) : `${personFirst}.${personLast}@temp.com`, + phoneNumber: phone ? String(phone) : undefined, + }, + }); + summary.peopleCreated += 1; + } + + if (person) { + if (mailingAddress) { + const existingAddress = await prisma.address.findUnique({ where: { personId: person.id } }).catch(() => null); + if (!existingAddress) { + await prisma.address.create({ data: { addressLine1: String(mailingAddress), addressLine2: null, city: "", state: "", zipCode: "", type: "Mailing", personId: person.id } }).catch(() => null); + } else if (!existingAddress.addressLine1) { + await prisma.address.update({ where: { id: existingAddress.id }, data: { addressLine1: String(mailingAddress) } }).catch(() => null); + } + } + let donor = await prisma.donor.findUnique({ where: { personId: person.id } }).catch(() => null); + if (!donor) { + donor = await prisma.donor.create({ data: { type: type, communicationPreference: preferredContact ?? "", status: "Active", notes: "", isRetained: false, personId: person.id } }); + summary.donorsCreated += 1; + } + donorId = donor.id; + if ( + preferredContact && + preferredContact !== donor.communicationPreference + ) { + await prisma.donor.update({ + where: { id: donor.id }, + data: { communicationPreference: String(preferredContact) }, + }).catch(() => null); + } + + } + + } else if (type == "Corporate" || type == "corporate" || type == "In-Kind" || type == "In-kind" || type == "in-kind" || type == "In Kind" || type == "In kind" || type == "in kind") { + let organization = null; + if (orgName) { + organization = await prisma.organization.findFirst({ where: { name: String(orgName) } }).catch(() => null); + } + if (!organization) { + organization = await prisma.organization.findUnique({ where: { emailAddress: String(orgEmail) } }).catch(() => null); + } + if (!organization) { + organization = await prisma.organization.create({ data: { name: String(orgName), emailAddress: orgEmail ? String(orgEmail) : email } }); + summary.organizationsCreated += 1; + } + if (mailingAddress) { + const existingAddr = await prisma.address.findUnique({ where: { organizationId: organization.id } }).catch(() => null); + if (!existingAddr) { + await prisma.address.create({ data: { addressLine1: String(mailingAddress), addressLine2: null, city: "", state: "", zipCode: "", type: "Mailing", organizationId: organization.id } }).catch(() => null); + } else if (!existingAddr.addressLine1) { + await prisma.address.update({ where: { id: existingAddr.id }, data: { addressLine1: String(mailingAddress) } }).catch(() => null); + } + } + // Ensure we also store a contact Person for organization-linked donors + let contactPerson = null; + if (personFirst || personLast || email) { + if (email) { + contactPerson = await prisma.person.findUnique({ where: { emailAddress: String(email) } }).catch(() => null); + } + if (!contactPerson && personFirst && personLast) { + contactPerson = await prisma.person.findFirst({ where: { firstName: String(personFirst), lastName: String(personLast) } }).catch(() => null); + } + if (!contactPerson) { + contactPerson = await prisma.person.create({ + data: { + firstName: personFirst ? String(personFirst) : (personLast ? String(personLast) : ""), + lastName: personLast ? String(personLast) : (personFirst ? String(personFirst) : ""), + emailAddress: email ? String(email) : `${(personFirst ?? "").toString().replace(/\s+/g, '')}.${(personLast ?? "").toString().replace(/\s+/g, '')}@temp.com`, + phoneNumber: phone ? String(phone) : undefined, + }, + }).catch(() => null); + if (contactPerson) summary.peopleCreated += 1; + } + + // Attach mailing address to the contact person if provided and missing + if (contactPerson && mailingAddress) { + const existingPersonAddr = await prisma.address.findUnique({ where: { personId: contactPerson.id } }).catch(() => null); + if (!existingPersonAddr) { + await prisma.address.create({ data: { addressLine1: String(mailingAddress), addressLine2: null, city: "", state: "", zipCode: "", type: "Mailing", personId: contactPerson.id } }).catch(() => null); + } else if (!existingPersonAddr.addressLine1) { + await prisma.address.update({ where: { id: existingPersonAddr.id }, data: { addressLine1: String(mailingAddress) } }).catch(() => null); + } + } + } + + let donor = await prisma.donor.findUnique({ where: { organizationId: organization.id } }).catch(() => null); + if (!donor) { + donor = await prisma.donor.create({ data: { type: type, communicationPreference: preferredContact ?? "", status: "Active", notes: "", isRetained: false, organizationId: organization.id, personId: contactPerson ? contactPerson.id : undefined } }).catch(() => null); + if (donor) summary.donorsCreated += 1; + } else { + // DOES NOT WORK + // if a donor exists for this organization but has no linked person (or linked person doesn't match what is stored in database), attach contactPerson + if ((contactPerson && !donor.personId) || (contactPerson && donor.personId !== contactPerson.id)) { + await prisma.donor.update({ where: { id: donor.id }, data: { personId: contactPerson.id } }).catch(() => null); + } + } + donorId = donor ? donor.id : null; + } + + // Create Donation record + if (donorId && amount > 0) { + await prisma.donation.create({ + data: { + type: "Donation", + amount: amount, + item: null, + paymentMethod: paymentMethod ?? null, + campaign: campaign ?? null, + fundDesignation: fund ?? "", + recurringFrequency: recurringFrequency ?? null, + date: date ?? new Date(), + source: "import", + isMatching: false, + taxDeductibleAmount: amount, + receiptSent: receiptSent, + receiptNumber: receiptNumber ?? undefined, + isAnonymous: isAnonymous, + acknowledgementSent: acknowledgementSent, + donorId: donorId, + }, + }); + summary.donationsCreated += 1; + } else if (donorId && amount <= 0) { + summary.errors.push(`Row ${idx}: Donation amount must be greater than 0 (found: ${amount})`); + } else if (!donorId && amount > 0) { + summary.errors.push(`Row ${idx}: Could not create or find donor for donation amount ${amount}`); + } + } catch (recErr) { + console.error(`Row ${idx} import error`, recErr); + summary.errors.push(`Row ${idx}: ${String(recErr)}`); + } + } + + return NextResponse.json( + { message: "CSV parsed and imported", summary, totalRowsProcessed: normalizedRecords.length }, + { status: 200 } + ); + } catch (error) { + console.error("Error processing request:", error); + return NextResponse.json( + { error: "Internal server error", details: String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/grants/export/route.ts b/app/api/admin/grants/export/route.ts new file mode 100644 index 0000000..f8d53b3 --- /dev/null +++ b/app/api/admin/grants/export/route.ts @@ -0,0 +1,279 @@ +import { NextResponse } from "next/server"; +import prisma from "@/app/utils/db"; +import * as xlsx from 'xlsx'; + +function formatCurrency(n: number | null | undefined) { + if (n === null || n === undefined) return ""; + try { + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(Number(n)); + } catch { + return String(n); + } +} + +function fmtDate(d?: Date | null) { + if (!d) return ""; + try { + // Render dates in UTC to avoid off-by-one shifts in spreadsheets + return new Intl.DateTimeFormat("en-US", { timeZone: "UTC" }).format(new Date(d)); + } catch { + return ""; + } +} + +// Exact header layout from the example; first column is intentionally blank. +const BASE_HEADER = [ + "", // leading blank column + "Assigned", + "Quarter", + "Funder", + "Funding Area", + "Kids-U Program ", + "Contact Type", + "LOI Due Date", + "Grant Due Date", + "Open-close dates", + "Funding Restrictions", + "Written Amount", + "Amount Awarded ", + "Notes", + "Resources", + "Link", +]; + +// Match the sheet’s width by padding with empty cells. +const TOTAL_COLUMNS = 28; + +function padToWidth(arr: string[], width = TOTAL_COLUMNS) { + const out = [...arr]; + while (out.length < width) out.push(""); + return out; +} + +// Quarter labels (section rows) +const QUARTER_LABELS: Record = { + Q1: "Q1 Jan, Feb, Mar", + Q2: "Q2 Apr, May, Jun", + Q3: "Q3 Jul, Aug, Sep", + Q4: "Q4 Oct, Nov, Dec", +}; + +const QUARTER_ORDER = ["Q1", "Q2", "Q3", "Q4"]; + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const params = url.searchParams; + + // Optional filters + const startDateParam = params.get("startDate"); + const endDateParam = params.get("endDate"); + const dueStartParam = params.get("dueStart"); + const dueEndParam = params.get("dueEnd"); + const fund = params.get("fund"); + const minAmount = params.get("minAmount"); + const maxAmount = params.get("maxAmount"); + const status = params.get("status"); + const applicationType = params.get("applicationType"); + const grantorType = params.get("grantorType"); + + const where: any = {}; + + if (startDateParam || endDateParam) { + where.startDate = {}; + if (startDateParam) where.startDate.gte = new Date(startDateParam); + if (endDateParam) where.startDate.lte = new Date(endDateParam); + } + // Filter by proposalDueDate when provided (inclusive range) + if (dueStartParam || dueEndParam) { + const range: any = {}; + if (dueStartParam) { + const start = new Date(`${dueStartParam}T00:00:00.000Z`); + if (!isNaN(start.getTime())) range.gte = start; + } + if (dueEndParam) { + const end = new Date(`${dueEndParam}T23:59:59.999Z`); + if (!isNaN(end.getTime())) range.lte = end; + } + if (Object.keys(range).length) { + where.proposalDueDate = range; + } + } + if (fund) { + where.fundingArea = { contains: fund, mode: "insensitive" }; + } + if (status) { + where.status = { equals: status, mode: "insensitive" }; + } + if (applicationType) { + where.applicationType = { equals: applicationType, mode: "insensitive" }; + } + if (minAmount || maxAmount) { + where.amountAwarded = {}; + if (minAmount) where.amountAwarded.gte = Number(minAmount); + if (maxAmount) where.amountAwarded.lte = Number(maxAmount); + } + if (grantorType) { + // Keep filter on the relation path that leads to the funder + where.representativeGrant = { + some: { + representative: { + grantor: { + type: grantorType, + }, + }, + }, + }; + } + + // 1) Fetch grants + const grants = await prisma.grant.findMany({ + where, + select: { + id: true, + name: true, + quarter: true, + internalOwner: true, + fundingArea: true, + useArea: true, + applicationType: true, + proposalDueDate: true, + fundingRestriction: true, + amountRequested: true, + amountAwarded: true, + startDate: true, + }, + orderBy: [{ quarter: "asc" }, { startDate: "desc" }], + }); + + // 2) Preload representatives/grantors/organizations for all returned grants + const grantIds = grants.map(g => g.id); + const reps = await prisma.representativeGrant.findMany({ + where: { grantId: { in: grantIds } }, + select: { + grantId: true, + representative: { + select: { + grantor: { + select: { + websiteLink: true, + organization: { + select: { + name: true, + emailAddress: true, + }, + }, + }, + }, + }, + }, + }, + }); + + // 3) Build lookup map with robust fallbacks + const byGrant = new Map< + string, + { + funderName: string; + orgEmail: string; + websiteLink: string; + } + >(); + + for (const gId of grantIds) { + const related = reps.filter(r => r.grantId === gId); + const funderName = + related.map(r => r.representative?.grantor?.organization?.name?.trim()).find(Boolean) ?? ""; + const orgEmail = + related.map(r => r.representative?.grantor?.organization?.emailAddress?.trim()).find(Boolean) ?? ""; + const websiteLink = + related.map(r => r.representative?.grantor?.websiteLink?.trim()).find(Boolean) ?? ""; + + byGrant.set(gId, { funderName, orgEmail, websiteLink }); + } + + // 4) Group by quarter to emit section rows + const grouped = new Map(); + for (const g of grants) { + const q = (g.quarter || "").toUpperCase(); + const key = QUARTER_ORDER.includes(q) ? q : "OTHER"; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(g); + } + + // 5) Build rows as array-of-arrays (AOA) for XLSX + const header = BASE_HEADER; + const aoa: any[] = []; + aoa.push(padToWidth(header)); + + // 6) Rows grouped by quarter + const order = [...QUARTER_ORDER, "OTHER"]; + for (const q of order) { + const list = grouped.get(q); + if (!list || list.length === 0) continue; + + const label = QUARTER_LABELS[q] || q; + aoa.push(padToWidth(["", label])); + + for (const g of list) { + const info = byGrant.get(g.id); + let funder = (info?.funderName ?? "").trim(); + const orgEmail = info?.orgEmail ?? ""; + const websiteLink = info?.websiteLink ?? ""; + + // Fallback: if no representative/org found, try to infer funder from the grant name + if (!funder) { + try { + const namePart = (g.name ?? "").toString().split("|")[0]?.trim(); + funder = namePart || (g.name ?? ""); + } catch (e) { + funder = g.name ?? ""; + } + } + + const notes = ""; + const resources = ""; + + const baseRow = [ + "", + g.internalOwner ?? "", + (g.quarter || "").toUpperCase(), + funder, + g.fundingArea ?? "", + g.useArea ?? "", + g.applicationType || orgEmail || "", + "", + fmtDate(g.proposalDueDate), + "", + g.fundingRestriction ?? "", + formatCurrency(g.amountRequested), + formatCurrency(g.amountAwarded), + notes, + resources, + websiteLink, + ]; + + aoa.push(padToWidth(baseRow)); + } + } + + const worksheet = xlsx.utils.aoa_to_sheet(aoa); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Grants'); + + const wbOut = xlsx.write(workbook, { bookType: 'xlsx', type: 'array' }); + const filename = 'Example-Excel-Sheets-Grants-and-Donors.xlsx'; + + const uint8 = typeof wbOut === 'object' && 'buffer' in wbOut ? wbOut : new Uint8Array(wbOut); + return new NextResponse(uint8.buffer, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (err) { + console.error("Export error:", err); + return NextResponse.json({ error: "Failed to export grants" }, { status: 500 }); + } +} diff --git a/app/api/admin/grants/import/route.ts b/app/api/admin/grants/import/route.ts new file mode 100644 index 0000000..8e40f85 --- /dev/null +++ b/app/api/admin/grants/import/route.ts @@ -0,0 +1,345 @@ +import { NextResponse, NextRequest } from "next/server"; +import { parse } from "csv-parse/sync"; +import prisma from "@/app/utils/db"; + +type Row = Record; + +function normalizeKey(k: string): string { + return (k ?? "").trim(); +} + +function normalizeStr(v: any): string | null { + if (v === undefined || v === null) return null; + const s = String(v).trim(); + return s.length ? s : null; +} + + +function parseDate(value: any): Date | null { + const v = normalizeStr(value); + if (!v) return null; + const d = new Date(String(v)); + return isNaN(d.getTime()) ? null : d; +} + +function parseBoolean(value: any): boolean { + const v = normalizeStr(value); + if (!v) return false; + const str = v.toLowerCase(); + const truthy = new Set(["true", "yes", "1", "y"]); + const falsy = new Set(["false", "no", "0", "n"]); + if (truthy.has(str)) return true; + if (falsy.has(str)) return false; + return false; +} + +function extractEmail(s: string | null): string | null { + if (!s) return null; + const m = s.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); + return m ? m[0] : null; +} + +function extractUrls(s: string | null): string[] { + if (!s) return []; + const matches = s.match(/https?:\/\/\S+/gi) ?? []; + return matches.map(u => u.replace(/[),.]+$/, "")); +} + +function parseCurrencyOrRange(v: any): number { + const raw = normalizeStr(v); + if (!raw) return 0; + + // Remove common currency symbols and thousands separators, normalize dashes + let s = String(raw).trim(); + // Handle parentheses for negatives: (1,000) -> -1000 + const parenMatch = s.match(/^\((.*)\)$/); + const negativeByParens = !!parenMatch; + if (negativeByParens && parenMatch) s = parenMatch[1]; + + s = s.replace(/[$£€¥]/g, ""); + // remove commas used as thousands separators + s = s.replace(/,/g, ""); + // normalize various dash types to hyphen + s = s.replace(/[\u2012\u2013\u2014\u2212]/g, "-"); + s = s.trim().toLowerCase(); + + const parseOne = (t: string): number => { + if (!t) return NaN; + let x = t.trim(); + // look for a number with optional decimal + const m = /(-?\d+(?:\.\d+)?)/.exec(x); + if (!m) return NaN; + let num = parseFloat(m[1]); + if (isNaN(num)) return NaN; + // support k/m shorthand (e.g., 5k -> 5000, 2.5m -> 2500000) + if (/k\b/.test(x)) num = num * 1000; + if (/m\b/.test(x)) num = num * 1000000; + return num; + }; + + // detect explicit range separators: ' to ' or ' - ' (with spaces) to avoid confusion with negative numbers + const rangeMatch = s.match(/^(.*)\s+(?:to|-)\s+(.*)$/i); + if (rangeMatch) { + const a = parseOne(rangeMatch[1]); + const b = parseOne(rangeMatch[2]); + const parts = [a, b].filter(n => isFinite(n)); + if (parts.length === 2) { + const avg = (parts[0] + parts[1]) / 2; + return negativeByParens ? -Math.abs(avg) : avg; + } + if (parts.length === 1) return negativeByParens ? -Math.abs(parts[0]) : parts[0]; + } + + const val = parseOne(s); + if (!isFinite(val)) return 0; + return negativeByParens ? -Math.abs(val) : val; +} + +function firstNonEmpty(row: Row, keys: string[]): string | null { + for (const k of keys) { + const v = row[k]; + const s = normalizeStr(v); + if (s) return s; + } + return null; +} + +function buildGrantName(args: { + funder: string | null; + kidsUProgram: string | null; + fundingArea: string | null; + quarter: string | null; + dueDate: string | null; + link: string | null; +}): string { + const parts = [ + args.funder, + args.kidsUProgram || args.fundingArea, + args.quarter, + args.dueDate, + args.link, + ].filter(Boolean) as string[]; + return parts.join(" | ").slice(0, 255) || "Unnamed Grant"; +} + +export async function POST(req: NextRequest) { + try { + const contentType = req.headers.get("content-type") || ""; + if (!contentType.startsWith("multipart/form-data")) { + return NextResponse.json( + { error: "Content-Type must be multipart/form-data" }, + { status: 415 } + ); + } + + const formData = await req.formData(); + const file = formData.get("csv") as File; + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); + } + const fileContent = await file.text(); + + const rawRecords = parse(fileContent, { + columns: true, + skip_empty_lines: true, + relax_column_count: true, + trim: true, + }) as Row[]; + + const records: Row[] = rawRecords.map((record) => { + const out: Row = {}; + for (const key in record) { + out[normalizeKey(key)] = + typeof record[key] === "string" ? record[key].trim() : record[key]; + } + return out; + }); + + const summary = { + organizationsCreated: 0, + grantorsCreated: 0, + grantsCreated: 0, + grantsUpdated: 0, + attachmentsCreated: 0, + skippedRows: 0, + errors: [] as string[], + }; + + for (let idx = 0; idx < records.length; idx++) { + const row = records[idx]; + + try { + // Exact columns from the provided CSV + const assigned = firstNonEmpty(row, ["Assigned"]); + const quarter = firstNonEmpty(row, ["Quarter"]); + const funder = firstNonEmpty(row, ["Funder"]); + const fundingArea = firstNonEmpty(row, ["Funding Area"]); + const kidsUProgram = firstNonEmpty(row, ["Kids-U Program", "Kids-U Program "]); + const contactType = firstNonEmpty(row, ["Contact Type"]); + const loiDue = firstNonEmpty(row, ["LOI Due Date"]); + const grantDue = firstNonEmpty(row, ["Grant Due Date"]); + const openClose = firstNonEmpty(row, ["Open-close dates"]); + const fundingRestrictions = firstNonEmpty(row, ["Funding Restrictions"]); + const writtenAmount = firstNonEmpty(row, ["Written Amount"]); + const amountAwardedRaw = firstNonEmpty(row, ["Amount Awarded", "Amount Awarded "]); + const link = firstNonEmpty(row, ["Link"]); + + const corePresent = + funder || grantDue || loiDue || writtenAmount || amountAwardedRaw || link; + const isQuarterBanner = + (quarter && /jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec/i.test(quarter)) && !funder; + if (!corePresent || isQuarterBanner) { + summary.skippedRows += 1; + continue; + } + + const orgName = funder ?? "Unknown Grantor"; + let organization = await prisma.organization.findFirst({ + where: { name: orgName }, + }); + + const contactEmail = extractEmail(contactType ?? ""); + if (!organization && contactEmail) { + organization = await prisma.organization.findUnique({ + where: { emailAddress: contactEmail }, + }).catch(() => null as any); + } + if (!organization) { + try { + organization = await prisma.organization.create({ + data: { name: orgName, emailAddress: contactEmail }, + }); + summary.organizationsCreated += 1; + } catch { + organization = + (await prisma.organization.findFirst({ where: { name: orgName } })) ?? + (await prisma.organization.create({ + data: { name: `${orgName} (dup)` }, + })); + summary.organizationsCreated += 1; + } + } + + let grantor = await prisma.grantor.findUnique({ + where: { organizationId: organization.id }, + }); + if (!grantor) { + grantor = await prisma.grantor.create({ + data: { + type: "Foundation", + websiteLink: null, + communicationPreference: + (contactEmail ? "Email" : normalizeStr(contactType) ?? "Unknown"), + recognitionPreference: "None", + internalRelationshipManager: "Unassigned", + organizationId: organization.id, + status: true, + }, + }); + summary.grantorsCreated += 1; + } + + const internalOwner = + assigned && assigned.toLowerCase() !== "not written" ? assigned : null; + const status = + assigned && assigned.toLowerCase() === "not written" ? "Not written" : "Planned"; + + const amountRequested = parseCurrencyOrRange(writtenAmount); + const amountAwarded = parseCurrencyOrRange(amountAwardedRaw); + + const internalProposalDueDate = parseDate(loiDue); + const proposalDueDate = parseDate(grantDue); + + const grantName = buildGrantName({ + funder: orgName, + kidsUProgram, + fundingArea, + quarter: quarter ?? null, + dueDate: grantDue ?? loiDue ?? null, + link: link ?? null, + }); + + // Proposal summary now excludes Notes and Resources (left blank) + const summaryPieces: string[] = []; + if (openClose) summaryPieces.push(`Open-close: ${openClose}`); + const proposalSummary = + summaryPieces.length ? summaryPieces.join(" | ").slice(0, 2000) : null; + + const grantData = { + name: grantName, + status, + amountRequested, + amountAwarded, + purpose: "General", + startDate: new Date(), + endDate: new Date(), + isMultipleYears: false, + quarter: quarter ?? "Unknown", + acknowledgementSent: false, + awardNotificationDate: null, + fundingArea: fundingArea ?? "Not specified", + internalProposalDueDate: internalProposalDueDate ?? null, + proposalDueDate: proposalDueDate ?? new Date(), + proposalSummary, + proposalSubmissionDate: null, + applicationType: + contactEmail ? "Email" : (normalizeStr(contactType) ?? "Not specified"), + internalOwner: internalOwner ?? "Unassigned", + fundingRestriction: fundingRestrictions ?? null, + matchingRequirement: null, + useArea: kidsUProgram ?? "Not specified", + isEligibleForRenewal: false, + renewalApplicationDate: null, + renewalAwardStatus: null, + }; + + const existingGrant = await prisma.grant.findUnique({ + where: { name: grantName }, + }); + + let grant; + if (!existingGrant) { + grant = await prisma.grant.create({ data: grantData }); + summary.grantsCreated += 1; + } else { + grant = await prisma.grant.update({ + where: { id: existingGrant.id }, + data: grantData, + }); + summary.grantsUpdated += 1; + } + + // Attachments only from Link now (Notes/Resources are ignored) + const urls = new Set(); + extractUrls(link).forEach((u) => urls.add(u)); + // Intentionally do NOT add URLs from Notes or Resources + + if (urls.size) { + const toCreate = Array.from(urls).map((u) => ({ + grantId: grant.id, + document: u, + })); + const res = await prisma.grantAttachment.createMany({ + data: toCreate, + skipDuplicates: true, + }); + summary.attachmentsCreated += res.count; + } + } catch (recErr: any) { + console.error(`Row ${idx} import error`, recErr); + summary.errors.push(`Row ${idx}: ${String(recErr?.message ?? recErr)}`); + } + } + + return NextResponse.json( + { message: "CSV parsed and imported (grants)", summary, totalRowsProcessed: records.length }, + { status: 200 } + ); + } catch (error: any) { + console.error("Error processing request:", error); + return NextResponse.json( + { error: "Internal server error", details: String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/import/route.ts b/app/api/admin/import/route.ts deleted file mode 100644 index 246bb53..0000000 --- a/app/api/admin/import/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextResponse, NextRequest } from "next/server"; -import { parse } from "csv-parse/sync"; - -type RecordType = { - [key: string]: string | number | null; -}; - -export async function POST(req: NextRequest) { - try { - const formData = await req.formData(); - const file = formData.get("csv") as File; - - if (!file) { - return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); - } - - const fileContent = await file.text(); - - const records = parse(fileContent, { - columns: true, - skip_empty_lines: true, - relax_column_count: true, - }); - - // Remvoe trailing white spaces in every cell - const normalizedRecords = records.map((record: Record) => { - const normalizedRecord: Record = {}; - - for (const key in record) { - const trimmedKey = key.trim(); - const value = record[key]; - - normalizedRecord[trimmedKey] = typeof value === "string" ? value.trim() : value; - } - - return normalizedRecord; - }); - - const funds = normalizedRecords.map((record: RecordType) => record["Kids-U Program"]).filter(Boolean); - console.log("Fund:", funds); - - return NextResponse.json({ message: "CSV parsed successfully" }, { status: 200 }); - } catch (error) { - console.error("Error processing request:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -} diff --git a/app/components/convertExcelToCSV.tsx b/app/components/convertExcelToCSV.tsx new file mode 100644 index 0000000..e3b5f9f --- /dev/null +++ b/app/components/convertExcelToCSV.tsx @@ -0,0 +1,19 @@ +"use client"; +import * as XLSX from 'xlsx'; + +// Convert an uploaded Excel File (from the browser) into CSV text. +// Returns CSV string of the first worksheet. +export default async function convertExcelToCSV(file: File): Promise { + const data = await file.arrayBuffer(); + const workbook = XLSX.read(data, { type: 'array' }); + if (!workbook.SheetNames.length) { + throw new Error('No sheets found in the uploaded Excel file.'); + } + + const firstSheetName = workbook.SheetNames[0]; + const csv = XLSX.utils.sheet_to_csv(workbook.Sheets[firstSheetName]); + if (!csv || csv.trim().length === 0) { + throw new Error('The worksheet is empty or could not be converted to CSV.'); + } + return csv; +} \ No newline at end of file diff --git a/app/components/import-csv.tsx b/app/components/import-csv.tsx index da551cb..f3ed2f4 100644 --- a/app/components/import-csv.tsx +++ b/app/components/import-csv.tsx @@ -3,6 +3,8 @@ import { Box, Button, TextField } from "@mui/material"; import { useState } from "react"; import { useForm } from "react-hook-form"; +// delete this file probably + export const Import = () => { const [file, setFile] = useState(null); diff --git a/package-lock.json b/package-lock.json index 4947344..bbaec83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.53.2", "react-router-dom": "^6.22.1", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/express": "^4.17.21", @@ -2227,6 +2228,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2852,6 +2862,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2959,6 +2982,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3103,6 +3135,18 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4484,6 +4528,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -7423,6 +7476,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.4.tgz", @@ -8333,6 +8398,24 @@ "node": ">= 0.6.0" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8451,6 +8534,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 235141a..72c7865 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.53.2", "react-router-dom": "^6.22.1", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/express": "^4.17.21", @@ -68,4 +69,4 @@ "eslintIgnore": [ "scripts/" ] -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 59ec5c4..e4dedf1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, "include": [ @@ -34,5 +40,7 @@ "frontend/src/pages/ApiTestPage.js", "app/(pages)/admin/grants/detail/[id]/page.tsx" ], - "exclude": ["node_modules"] -} + "exclude": [ + "node_modules" + ] +} \ No newline at end of file