Spaces:
Build error
Build error
| '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> | |
| ) | |
| } | |