RFP_Space_V05 / components /RFPStatusTable.tsx
Varun10000's picture
Upload 89 files
17cf14d verified
Raw
History Blame Contribute Delete
16.5 kB
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useAuth } from '@/lib/auth'
const statusOptions = [
'Intake',
'Writing',
'Review',
'Final QA',
'Submitted',
'Active',
'Draft',
'In Progress',
'Under Review',
'Won',
'Lost',
'No Bid',
]
type RfpRow = {
id: string
title: string
agency?: string | null
status: string
submissionDeadline: string
lastFollowedUp?: string | null
notes?: string | null
partnerSelections?: string[]
}
type PartnerOption = {
id: string
name: string
}
export default function RFPStatusTable({ rfps, partners }: { rfps: RfpRow[]; partners: PartnerOption[] }) {
const router = useRouter()
const { user } = useAuth()
const [updatingId, setUpdatingId] = useState<string | null>(null)
const [editingNotes, setEditingNotes] = useState<{ [key: string]: string }>({})
const [updatingPartnerId, setUpdatingPartnerId] = useState<string | null>(null)
const [updatingFollowUpId, setUpdatingFollowUpId] = useState<string | null>(null)
// Filter state
const [statusFilter, setStatusFilter] = useState<string>('all')
const [partnerFilter, setPartnerFilter] = useState<string>('all')
// Partner dropdown open state
const [openPartnerDropdown, setOpenPartnerDropdown] = useState<string | null>(null)
// Track if we're doing a local update (don't sync from props)
const skipNextSync = useRef(false)
// Close partner dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (openPartnerDropdown && !(e.target as Element).closest('.partner-dropdown-container')) {
setOpenPartnerDropdown(null)
}
}
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}, [openPartnerDropdown])
// Track follow-up dates locally
const [followUpMap, setFollowUpMap] = useState<Record<string, string | null>>(() => {
const initial: Record<string, string | null> = {}
rfps.forEach((r) => {
initial[r.id] = r.lastFollowedUp || null
})
return initial
})
const [statusMap, setStatusMap] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}
rfps.forEach((r) => {
initial[r.id] = r.status
})
return initial
})
// Keep local partner selections so the UI reflects changes immediately
const [partnerSelectionsMap, setPartnerSelectionsMap] = useState<Record<string, string[]>>(() => {
const initial: Record<string, string[]> = {}
rfps.forEach((r) => {
const list = r.partnerSelections && r.partnerSelections.length ? r.partnerSelections : ['Own']
initial[r.id] = list
})
return initial
})
// Sync state when rfps props change (after router.refresh()) - but only for NEW RFPs
useEffect(() => {
if (skipNextSync.current) {
skipNextSync.current = false
return
}
const newFollowUpMap: Record<string, string | null> = {}
const newStatusMap: Record<string, string> = {}
const newPartnerMap: Record<string, string[]> = {}
rfps.forEach((r) => {
// Preserve local state if we already have it, otherwise use prop value
newFollowUpMap[r.id] = followUpMap[r.id] !== undefined ? followUpMap[r.id] : (r.lastFollowedUp || null)
newStatusMap[r.id] = statusMap[r.id] !== undefined ? statusMap[r.id] : r.status
newPartnerMap[r.id] = partnerSelectionsMap[r.id] !== undefined
? partnerSelectionsMap[r.id]
: (r.partnerSelections && r.partnerSelections.length ? r.partnerSelections : ['Own'])
})
setFollowUpMap(newFollowUpMap)
setStatusMap(newStatusMap)
setPartnerSelectionsMap(newPartnerMap)
}, [rfps])
// Filtered RFPs
const filteredRfps = useMemo(() => {
return rfps.filter((rfp) => {
// Status filter
if (statusFilter !== 'all' && (statusMap[rfp.id] || rfp.status) !== statusFilter) {
return false
}
// Partner filter
const selections = partnerSelectionsMap[rfp.id] || ['Own']
if (partnerFilter === 'own' && !selections.includes('Own')) {
return false
}
if (partnerFilter === 'partner' && selections.length === 1 && selections[0] === 'Own') {
return false
}
return true
})
}, [rfps, statusFilter, partnerFilter, statusMap, partnerSelectionsMap])
const handleStatusChange = async (id: string, newStatus: string, prevStatus: string) => {
setUpdatingId(id)
try {
if (!user?.id) {
alert('Please log in to update status')
setStatusMap((prev) => ({ ...prev, [id]: prevStatus }))
return
}
await fetch(`/api/rfps/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || '',
},
body: JSON.stringify({ status: newStatus }),
})
router.refresh()
} catch (err) {
console.error('Failed to update status', err)
setStatusMap((prev) => ({ ...prev, [id]: prevStatus }))
alert('Could not update status. Please try again.')
} finally {
setUpdatingId(null)
}
}
const handlePartnerChange = async (id: string, newPartnerSelections: string[]) => {
setUpdatingPartnerId(id)
skipNextSync.current = true
try {
if (!user?.id) {
alert('Please log in to update partner selection')
return
}
await fetch(`/api/rfps/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || '',
},
body: JSON.stringify({ partnerSelections: JSON.stringify(newPartnerSelections) }),
})
// Don't call router.refresh() - just keep local state
} catch (err) {
console.error('Failed to update partner selection', err)
alert('Could not update partner selection. Please try again.')
} finally {
setUpdatingPartnerId(null)
}
}
const handleNotesBlur = async (id: string, notes: string) => {
skipNextSync.current = true
try {
if (!user?.id) {
alert('Please log in to update notes')
return
}
await fetch(`/api/rfps/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || '',
},
body: JSON.stringify({ notes }),
})
// Don't call router.refresh() - just keep local state
} catch (err) {
console.error('Failed to update notes', err)
alert('Could not update notes. Please try again.')
}
}
const handleFollowUpChange = async (id: string, dateValue: string) => {
setUpdatingFollowUpId(id)
setFollowUpMap((prev) => ({ ...prev, [id]: dateValue || null }))
skipNextSync.current = true
try {
if (!user?.id) {
alert('Please log in to update follow-up date')
return
}
await fetch(`/api/rfps/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || '',
},
body: JSON.stringify({ lastFollowedUp: dateValue || null }),
})
// Don't call router.refresh() - just keep local state
} catch (err) {
console.error('Failed to update follow-up date', err)
alert('Could not update follow-up date. Please try again.')
} finally {
setUpdatingFollowUpId(null)
}
}
return (
<div className="card-cozy">
<div className="flex items-center justify-between mb-4 flex-wrap gap-4">
<h3 className="text-lg font-semibold text-gray-900">Manual Status Table</h3>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Status:</label>
<select
className="input-cozy text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All</option>
{statusOptions.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Partners:</label>
<select
className="input-cozy text-sm"
value={partnerFilter}
onChange={(e) => setPartnerFilter(e.target.value)}
>
<option value="all">All</option>
<option value="own">Own (Prime)</option>
<option value="partner">With Partners</option>
</select>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-600 border-b">
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Agency</th>
<th className="py-2 pr-4">Submission Deadline</th>
<th className="py-2 pr-4">Partners</th>
<th className="py-2 pr-4">Status</th>
<th className="py-2 pr-4">Last Followed Up</th>
<th className="py-2 pr-4">Notes</th>
</tr>
</thead>
<tbody>
{filteredRfps.map((rfp) => {
const selectedList = partnerSelectionsMap[rfp.id] || ['Own']
const isOwnOnly = selectedList.length === 1 && selectedList[0] === 'Own'
const isDropdownOpen = openPartnerDropdown === rfp.id
const selectedPartnerNames = selectedList
.filter((id) => id !== 'Own')
.map((id) => {
const p = partners.find((p) => p.id === id)
return p ? p.name : id
})
return (
<tr key={rfp.id} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium text-gray-900">{rfp.title}</td>
<td className="py-2 pr-4 text-gray-700">{rfp.agency || '—'}</td>
<td className="py-2 pr-4 text-gray-700">{new Date(rfp.submissionDeadline).toLocaleDateString()}</td>
<td className="py-2 pr-4">
<div className="relative partner-dropdown-container">
<div className="flex gap-2">
<button
type="button"
className={`px-3 py-1 text-xs rounded-l border transition-colors ${
isOwnOnly
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
onClick={() => {
setPartnerSelectionsMap((prev) => ({ ...prev, [rfp.id]: ['Own'] }))
handlePartnerChange(rfp.id, ['Own'])
setOpenPartnerDropdown(null)
}}
>
Own
</button>
<button
type="button"
className={`px-3 py-1 text-xs rounded-r border transition-colors ${
!isOwnOnly
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
onClick={() => setOpenPartnerDropdown(isDropdownOpen ? null : rfp.id)}
>
Partner {!isOwnOnly && selectedPartnerNames.length > 0 && `(${selectedPartnerNames.length})`}
</button>
</div>
{isDropdownOpen && (
<div className="absolute z-50 mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg py-2 max-h-48 overflow-y-auto">
{partners.map((partner) => {
const checked = selectedList.includes(partner.id)
return (
<label
key={partner.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer text-sm"
>
<input
type="checkbox"
className="accent-blue-600"
checked={checked}
onChange={(e) => {
let next: string[]
if (e.target.checked) {
next = [...selectedList.filter((id) => id !== 'Own'), partner.id]
} else {
next = selectedList.filter((id) => id !== partner.id)
if (!next.length) next = ['Own']
}
setPartnerSelectionsMap((prev) => ({ ...prev, [rfp.id]: next }))
handlePartnerChange(rfp.id, next)
}}
/>
<span className="text-gray-700">{partner.name}</span>
</label>
)
})}
{partners.length === 0 && (
<p className="px-3 py-2 text-sm text-gray-500">No partners configured</p>
)}
</div>
)}
{!isOwnOnly && selectedPartnerNames.length > 0 && (
<div className="mt-1 text-xs text-gray-500">
{selectedPartnerNames.join(', ')}
</div>
)}
</div>
</td>
<td className="py-2 pr-4">
<select
className="input-cozy"
value={statusMap[rfp.id] ?? rfp.status}
disabled={updatingId === rfp.id}
onChange={(e) => {
const next = e.target.value
const prev = statusMap[rfp.id] ?? rfp.status
setStatusMap((current) => ({ ...current, [rfp.id]: next }))
handleStatusChange(rfp.id, next, prev)
}}
>
{statusOptions.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</td>
<td className="py-2 pr-4">
<input
type="date"
className="input-cozy text-sm"
value={followUpMap[rfp.id]?.split('T')[0] || ''}
disabled={updatingFollowUpId === rfp.id}
onChange={(e) => handleFollowUpChange(rfp.id, e.target.value)}
/>
</td>
<td className="py-2 pr-4">
<textarea
className="input-cozy w-full min-w-[200px] text-sm"
rows={2}
placeholder="Add notes..."
defaultValue={rfp.notes || ''}
onBlur={(e) => handleNotesBlur(rfp.id, e.target.value)}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}