From 8ea84699a0828ba96c41d897592db8fa9ab40c18 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Thu, 5 Feb 2026 09:59:17 +0100 Subject: [PATCH 1/4] feat: enhance SponsorCard with selection and email functionality - Added selection checkbox and email button to SponsorCard component. - Updated styles for selected state in SponsorCard. - Introduced priority tags display for sponsors. - Refactored invoice status and assignee display logic. feat: create SponsorContactEditor for managing contact information - Implemented SponsorContactEditor component to add, update, and remove contact persons. - Added billing information management within the editor. - Integrated notification system for success and error handling during updates. feat: add bulk update and delete functionality for sponsors - Implemented bulkUpdate and bulkDelete mutations in sponsor router. - Created schemas for bulk update and delete operations. - Enhanced error handling and logging for bulk operations. --- .../admin/api/sponsors/email/send/route.ts | 85 +++ .../admin/sponsors/crm/SponsorCRMClient.tsx | 136 +++- src/app/(admin)/admin/sponsors/crm/page.tsx | 16 +- src/components/admin/SponsorContactTable.tsx | 543 +++------------- .../admin/SponsorIndividualEmailModal.tsx | 171 +++++ .../admin/sponsor-crm/SponsorBoardColumn.tsx | 11 + .../admin/sponsor-crm/SponsorBulkActions.tsx | 330 ++++++++++ .../admin/sponsor-crm/SponsorCRMForm.tsx | 605 +++++++++++------- .../admin/sponsor-crm/SponsorCard.tsx | 163 +++-- .../admin/sponsor/SponsorContactEditor.tsx | 367 +++++++++++ src/server/routers/sponsor.ts | 212 +++++- src/server/schemas/sponsorForConference.ts | 19 + 12 files changed, 1888 insertions(+), 770 deletions(-) create mode 100644 src/app/(admin)/admin/api/sponsors/email/send/route.ts create mode 100644 src/components/admin/SponsorIndividualEmailModal.tsx create mode 100644 src/components/admin/sponsor-crm/SponsorBulkActions.tsx create mode 100644 src/components/admin/sponsor/SponsorContactEditor.tsx diff --git a/src/app/(admin)/admin/api/sponsors/email/send/route.ts b/src/app/(admin)/admin/api/sponsors/email/send/route.ts new file mode 100644 index 00000000..33e84ae2 --- /dev/null +++ b/src/app/(admin)/admin/api/sponsors/email/send/route.ts @@ -0,0 +1,85 @@ +import { auth, NextAuthRequest } from '@/lib/auth' +import { + setupEmailRoute, + convertPortableTextToHTML, + renderEmailTemplate, + createEmailSuccessResponse, + createEmailErrorResponse, +} from '@/lib/email/route-helpers' +import { resend, retryWithBackoff } from '@/lib/email/config' +import { getSponsor } from '@/lib/sponsor/sanity' + +export const POST = auth(async (req: NextAuthRequest) => { + try { + const body = await req.json() + const { sponsorId } = body + + if (!sponsorId) { + return createEmailErrorResponse('sponsorId is required', 400) + } + + const { context, error } = await setupEmailRoute(req, body, { + sponsors: true, + sponsorContact: true, + }) + + if (error) return error + + const { conference, messagePortableText, subject } = context! + + // Fetch sponsor contacts + const { sponsor, error: sponsorError } = await getSponsor(sponsorId, true) + if (sponsorError || !sponsor) { + return createEmailErrorResponse( + sponsorError?.message || 'Sponsor not found', + 404, + ) + } + + const contacts = 'contact_persons' in sponsor ? sponsor.contact_persons : [] + const recipients = contacts + ?.filter((c) => c.email) + .map((c) => ({ email: c.email, name: c.name })) + + if (!recipients || recipients.length === 0) { + return createEmailErrorResponse( + 'Sponsor has no contact persons with email addresses', + 400, + ) + } + + const { htmlContent, error: htmlError } = + await convertPortableTextToHTML(messagePortableText) + if (htmlError) return htmlError + + const emailTemplate = renderEmailTemplate({ + conference, + subject, + htmlContent: htmlContent!, + }) + + const result = await retryWithBackoff(async () => { + return await resend.emails.send({ + from: `${conference.organizer || 'Cloud Native Days'} <${conference.sponsor_email}>`, + to: recipients.map((r) => r.email), + subject, + react: emailTemplate, + }) + }) + + if (result.error) { + return createEmailErrorResponse(result.error.message, 500) + } + + return createEmailSuccessResponse({ + emailId: result.data?.id, + recipientCount: recipients.length, + }) + } catch (err) { + console.error('[SponsorIndividualEmail] Unexpected error:', err) + return createEmailErrorResponse( + err instanceof Error ? err.message : 'Failed to send email', + 500, + ) + } +}) diff --git a/src/app/(admin)/admin/sponsors/crm/SponsorCRMClient.tsx b/src/app/(admin)/admin/sponsors/crm/SponsorCRMClient.tsx index 3ea5d52f..bfb967dd 100644 --- a/src/app/(admin)/admin/sponsors/crm/SponsorCRMClient.tsx +++ b/src/app/(admin)/admin/sponsors/crm/SponsorCRMClient.tsx @@ -9,25 +9,38 @@ import { useState, useEffect } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { SponsorCRMForm } from '@/components/admin/sponsor-crm/SponsorCRMForm' import { ImportHistoricSponsorsButton } from '@/components/admin/sponsor-crm/ImportHistoricSponsorsButton' +import { SponsorIndividualEmailModal } from '@/components/admin/SponsorIndividualEmailModal' import { BoardViewSwitcher, type BoardView, } from '@/components/admin/sponsor-crm/BoardViewSwitcher' import { SponsorBoardColumn } from '@/components/admin/sponsor-crm/SponsorBoardColumn' +import { SponsorBulkActions } from '@/components/admin/sponsor-crm/SponsorBulkActions' import { FilterDropdown, FilterOption } from '@/components/admin/FilterDropdown' -import { XMarkIcon } from '@heroicons/react/20/solid' +import { XMarkIcon, CheckIcon } from '@heroicons/react/20/solid' +import { Conference } from '@/lib/conference/types' interface SponsorCRMClientProps { conferenceId: string + conference: Conference + domain: string } -export function SponsorCRMClient({ conferenceId }: SponsorCRMClientProps) { +export function SponsorCRMClient({ + conferenceId, + conference, + domain, +}: SponsorCRMClientProps) { const router = useRouter() const searchParams = useSearchParams() const [selectedSponsor, setSelectedSponsor] = useState(null) + const [emailSponsor, setEmailSponsor] = + useState(null) + const [selectedIds, setSelectedIds] = useState([]) const [isFormOpen, setIsFormOpen] = useState(false) + const [isEmailModalOpen, setIsEmailModalOpen] = useState(false) const [currentView, setCurrentView] = useState('pipeline') // Parse filters from URL @@ -82,6 +95,37 @@ export function SponsorCRMClient({ conferenceId }: SponsorCRMClientProps) { setIsFormOpen(false) } + const handleOpenEmail = (sponsor: SponsorForConferenceExpanded) => { + setEmailSponsor(sponsor) + setIsEmailModalOpen(true) + } + + const handleCloseEmail = () => { + setEmailSponsor(null) + setIsEmailModalOpen(false) + } + + const handleToggleSelect = (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id], + ) + } + + const handleClearSelection = () => { + setSelectedIds([]) + } + + const handleSelectAllFiltered = () => { + const allFilteredIds = filteredSponsors.map((s) => s._id) + setSelectedIds(allFilteredIds) + } + + // Clear selection when filters change to avoid accidental bulk updates on hidden items + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Clear selection when view/filters change + handleClearSelection() + }, [tiersFilter.length, assignedToFilter, tagsFilter?.length, currentView]) + // CMD+O / CTRL+O keyboard shortcut to open new sponsor form useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -238,19 +282,50 @@ export function SponsorCRMClient({ conferenceId }: SponsorCRMClientProps) { const columns = getColumns() + if (!conferenceId) { + return ( +
+

No conference selected

+
+ ) + } + return (
{/* Form Modal */} - { - utils.sponsor.crm.list.invalidate() - }} - existingSponsorsInCRM={sponsors.map((s) => s.sponsor._id)} - /> + {isFormOpen && ( + { + utils.sponsor.crm.list.invalidate() + }} + existingSponsorsInCRM={sponsors.map((s) => s.sponsor._id)} + /> + )} + + {/* Email Modal */} + {emailSponsor && conference && ( + + )} {/* Filters and View Switcher */}
@@ -330,14 +405,23 @@ export function SponsorCRMClient({ conferenceId }: SponsorCRMClientProps) { {/* Clear Filters */} {activeFilterCount > 0 && ( - +
+ + +
)}
@@ -355,6 +439,15 @@ export function SponsorCRMClient({ conferenceId }: SponsorCRMClientProps) {
+ {/* Bulk Actions Bar */} + { + utils.sponsor.crm.list.invalidate() + }} + /> + {/* Board Columns */}
handleOpenForm()} emptyMessage="No sponsors" /> diff --git a/src/app/(admin)/admin/sponsors/crm/page.tsx b/src/app/(admin)/admin/sponsors/crm/page.tsx index 72b9d9ff..7f1584c1 100644 --- a/src/app/(admin)/admin/sponsors/crm/page.tsx +++ b/src/app/(admin)/admin/sponsors/crm/page.tsx @@ -2,10 +2,18 @@ import { getConferenceForCurrentDomain } from '@/lib/conference/sanity' import { ErrorDisplay, AdminPageHeader } from '@/components/admin' import { SponsorCRMClient } from './SponsorCRMClient' import { BuildingOffice2Icon } from '@heroicons/react/24/outline' +import { headers } from 'next/headers' export default async function AdminSponsorsCRM() { + const headerList = await headers() + const domain = headerList.get('host') || 'localhost:3000' + const { conference, error: conferenceError } = - await getConferenceForCurrentDomain({}) + await getConferenceForCurrentDomain({ + sponsors: true, + sponsorContact: true, + sponsorTiers: true, + }) if (conferenceError || !conference) { return ( @@ -27,7 +35,11 @@ export default async function AdminSponsorsCRM() { backLink={{ href: '/admin/sponsors', label: 'Back to Dashboard' }} /> - +
) } diff --git a/src/components/admin/SponsorContactTable.tsx b/src/components/admin/SponsorContactTable.tsx index d5717338..b35039f0 100644 --- a/src/components/admin/SponsorContactTable.tsx +++ b/src/components/admin/SponsorContactTable.tsx @@ -1,21 +1,26 @@ 'use client' +import { useState, useEffect, Fragment } from 'react' import { SponsorWithContactInfo, ContactPerson } from '@/lib/sponsor/types' import { EnvelopeIcon, BuildingOffice2Icon, ClipboardIcon, PencilIcon, - CheckIcon as CheckIconOutline, XMarkIcon, - PlusIcon, } from '@heroicons/react/24/outline' import { CheckIcon } from '@heroicons/react/24/solid' -import { useState, useEffect } from 'react' -import { ContactRoleSelect } from '@/components/common/ContactRoleSelect' import { api } from '@/lib/trpc/client' import { useNotification } from './NotificationProvider' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' +import { SponsorContactEditor } from './sponsor/SponsorContactEditor' +import { + Dialog, + DialogPanel, + DialogTitle, + Transition, + TransitionChild, +} from '@headlessui/react' interface SponsorContactTableProps { sponsors: SponsorWithContactInfo[] @@ -63,235 +68,33 @@ interface ContactRow { isFirstContactForSponsor: boolean } -interface EditingContact { - name: string - email: string - phone: string - role: string - billing?: { - email: string - reference: string - comments: string - } -} - export function SponsorContactTable({ sponsors: initialSponsors, }: SponsorContactTableProps) { const [sponsors, setSponsors] = useState(initialSponsors) - const [editingRowId, setEditingRowId] = useState(null) - const [editingContact, setEditingContact] = useState({ - name: '', - email: '', - phone: '', - role: '', - billing: { - email: '', - reference: '', - comments: '', - }, - }) - const [savingRowId, setSavingRowId] = useState(null) - const { showNotification } = useNotification() + const [editingSponsor, setEditingSponsor] = + useState(null) const utils = api.useUtils() useEffect(() => { setSponsors(initialSponsors) }, [initialSponsors]) - const resetEditingState = () => { - setEditingRowId(null) - setEditingContact({ - name: '', - email: '', - phone: '', - role: '', - billing: { - email: '', - reference: '', - comments: '', - }, - }) - } - - const updateSponsorMutation = api.sponsor.update.useMutation({ - onSuccess: async (updatedSponsor) => { - setSponsors((prevSponsors) => - prevSponsors.map((sponsor) => - sponsor._id === updatedSponsor._id ? updatedSponsor : sponsor, - ), - ) - - await utils.sponsor.list.invalidate() - - showNotification({ - type: 'success', - title: 'Contact updated', - message: 'Sponsor contact information has been successfully updated.', - }) - setSavingRowId(null) - resetEditingState() - }, - onError: (error: { message: string }) => { - showNotification({ - type: 'error', - title: 'Update failed', - message: - error.message || - 'Failed to update contact information. Please try again.', - }) - setSavingRowId(null) - }, - }) - - const handleStartEdit = (row: ContactRow) => { - const rowId = `${row.sponsor._id}-${row.contact._key}` - setEditingRowId(rowId) - setEditingContact({ - name: row.contact.name || '', - email: row.contact.email || '', - phone: row.contact.phone || '', - role: row.contact.role || '', - billing: { - email: row.sponsor.billing?.email || '', - reference: row.sponsor.billing?.reference || '', - comments: row.sponsor.billing?.comments || '', - }, - }) - } - - const handleCancelEdit = () => { - resetEditingState() + const handleStartEdit = (sponsor: SponsorWithContactInfo) => { + setEditingSponsor(sponsor) } - const handleAddContact = (sponsorId: string) => { - const newRowId = `${sponsorId}-new-contact-${Date.now()}` - setEditingRowId(newRowId) - setEditingContact({ - name: '', - email: '', - phone: '', - role: '', - billing: { - email: '', - reference: '', - comments: '', - }, - }) + const handleCloseEdit = () => { + setEditingSponsor(null) } - const handleSaveEdit = async (row: ContactRow) => { - const rowId = `${row.sponsor._id}-${row.contact._key}` - - if ( - editingContact.role === 'Billing Reference' && - !editingContact.billing?.email - ) { - showNotification({ - type: 'warning', - title: 'Billing email required', - message: - 'Please provide a billing email for Billing Reference contacts.', - }) - return - } - - setSavingRowId(rowId) - - try { - let updatedContactPersons: ContactPerson[] - - if ( - row.contact._key === 'no-contact' || - row.contact._key === 'new-contact-temp' - ) { - const newContact: ContactPerson = { - // eslint-disable-next-line react-hooks/purity -- Unique key generation for new contact - _key: `contact-${Date.now()}`, - name: editingContact.name, - email: editingContact.email, - phone: editingContact.phone || undefined, - role: editingContact.role || undefined, - } - - if (row.contact._key === 'no-contact') { - updatedContactPersons = [newContact] - } else { - updatedContactPersons = [ - ...(row.sponsor.contact_persons || []), - newContact, - ] - } - } else { - updatedContactPersons = - row.sponsor.contact_persons?.map((contact) => - contact._key === row.contact._key - ? { - ...contact, - name: editingContact.name, - email: editingContact.email, - phone: editingContact.phone || undefined, - role: editingContact.role || undefined, - } - : contact, - ) || [] - } - - const isBillingReference = editingContact.role === 'Billing Reference' - let billingData = undefined - - if (isBillingReference && editingContact.billing?.email?.trim()) { - billingData = { - email: editingContact.billing.email.trim(), - reference: editingContact.billing.reference?.trim() || undefined, - comments: editingContact.billing.comments?.trim() || undefined, - } - } else if ( - !isBillingReference && - row.sponsor.billing && - row.sponsor.billing.email - ) { - billingData = { - email: row.sponsor.billing.email, - reference: row.sponsor.billing.reference || undefined, - comments: row.sponsor.billing.comments || undefined, - } - } - - const updateData: { - name: string - website: string - logo: string - org_number?: string - contact_persons: ContactPerson[] - billing?: { - email: string - reference?: string - comments?: string - } - } = { - name: row.sponsor.name, - website: row.sponsor.website, - logo: row.sponsor.logo, - contact_persons: updatedContactPersons, - } - - if (row.sponsor.org_number) { - updateData.org_number = row.sponsor.org_number - } - - if (billingData && billingData.email && billingData.email.trim()) { - updateData.billing = billingData - } - - await updateSponsorMutation.mutateAsync({ - id: row.sponsor._id, - data: updateData, - }) - } catch { - // Silently fail - error will be shown by mutation's onError handler - } + const handleUpdateSuccess = (updatedSponsor: SponsorWithContactInfo) => { + setSponsors((prev) => + prev.map((s) => (s._id === updatedSponsor._id ? updatedSponsor : s)), + ) + handleCloseEdit() + utils.sponsor.list.invalidate() } const contactRows: ContactRow[] = [] @@ -305,23 +108,6 @@ export function SponsorContactTable({ isFirstContactForSponsor: index === 0, }) }) - - if ( - editingRowId && - editingRowId.startsWith(`${sponsor._id}-new-contact-`) - ) { - contactRows.push({ - sponsor, - contact: { - _key: 'new-contact-temp', - name: '', - email: '', - phone: '', - role: '', - }, - isFirstContactForSponsor: false, - }) - } } else { contactRows.push({ sponsor, @@ -351,43 +137,101 @@ export function SponsorContactTable({ return (
+ {/* Editor Modal */} + + + +
+ + +
+
+ + +
+ + Manage Contacts: {editingSponsor?.name} + + +
+ + {editingSponsor && ( +
+ +
+ )} +
+
+
+
+
+
+
@@ -401,15 +245,6 @@ export function SponsorContactTable({ {contactRows.map((row, index) => { - const rowId = - row.contact._key === 'new-contact-temp' && - editingRowId?.startsWith(`${row.sponsor._id}-new-contact-`) - ? editingRowId - : `${row.sponsor._id}-${row.contact._key}` - const isEditing = editingRowId === rowId - const isSaving = - savingRowId === rowId || updateSponsorMutation.isPending - return (
Sponsor Contact Name Contact Email Phone Role Billing Info
-
+
{row.sponsor.name}
{row.sponsor.org_number && ( @@ -428,24 +263,7 @@ export function SponsorContactTable({
- {isEditing ? ( - - setEditingContact({ - ...editingContact, - name: e.target.value, - }) - } - className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" - placeholder="Contact name" - /> - ) : row.contact._key === 'new-contact-temp' ? ( -
- Add new contact -
- ) : row.contact.name ? ( + {row.contact.name ? (
{row.contact.name}
@@ -456,24 +274,7 @@ export function SponsorContactTable({ )}
- {isEditing ? ( - - setEditingContact({ - ...editingContact, - email: e.target.value, - }) - } - className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" - placeholder="Email address" - /> - ) : row.contact._key === 'new-contact-temp' ? ( -
- - -
- ) : row.contact.email ? ( + {row.contact.email ? (
- {isEditing ? ( - - setEditingContact({ - ...editingContact, - phone: e.target.value, - }) - } - className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" - placeholder="Phone number" - /> - ) : row.contact._key === 'new-contact-temp' ? ( -
- - -
- ) : row.contact.phone ? ( + {row.contact.phone ? (
- {isEditing ? ( - - setEditingContact({ - ...editingContact, - role: value, - }) - } - className="w-full" - placeholder="Select role..." - /> - ) : row.contact._key === 'new-contact-temp' ? ( -
- - -
- ) : row.contact.role ? ( + {row.contact.role ? (
{row.contact.role}
@@ -552,61 +320,7 @@ export function SponsorContactTable({
{row.isFirstContactForSponsor && (
- {isEditing && - editingContact.role === 'Billing Reference' ? ( - <> -
- Edit Billing Information -
-
- - setEditingContact({ - ...editingContact, - billing: { - ...editingContact.billing!, - email: e.target.value, - }, - }) - } - className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" - placeholder="Billing email" - /> - - setEditingContact({ - ...editingContact, - billing: { - ...editingContact.billing!, - reference: e.target.value, - }, - }) - } - className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" - placeholder="Billing reference" - /> -