Spaces:
Runtime error
Runtime error
| 'use client'; | |
| import React, { useState, useEffect } from 'react'; | |
| import api, { Project, CreateProjectData, RFPOpportunity, SavedOpportunity } from '../services/api'; | |
| interface ProjectModalProps { | |
| project: Project | null; | |
| onSave: (projectData: CreateProjectData, projectId?: string) => void; | |
| onClose: () => void; | |
| } | |
| type ModalStep = 'select' | 'opportunities' | 'manual'; | |
| // Unified opportunity type for display | |
| interface DisplayOpportunity { | |
| id: string; | |
| title: string; | |
| department: string; | |
| description?: string; | |
| response_deadline?: string; | |
| source?: string; | |
| url?: string; | |
| } | |
| const ProjectModal: React.FC<ProjectModalProps> = ({ project, onSave, onClose }) => { | |
| const [step, setStep] = useState<ModalStep>(project ? 'manual' : 'select'); | |
| // Opportunities state - now uses database opportunities | |
| const [opportunities, setOpportunities] = useState<DisplayOpportunity[]>([]); | |
| const [loadingOpps, setLoadingOpps] = useState(false); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [selectedOpp, setSelectedOpp] = useState<DisplayOpportunity | null>(null); | |
| // Form state | |
| const [name, setName] = useState(''); | |
| const [client, setClient] = useState(''); | |
| const [dueDate, setDueDate] = useState(''); | |
| const [description, setDescription] = useState(''); | |
| useEffect(() => { | |
| if (project) { | |
| setName(project.name); | |
| setClient(project.client); | |
| setDueDate(project.due_date ? new Date(project.due_date).toISOString().split('T')[0] : ''); | |
| setDescription(project.description || ''); | |
| setStep('manual'); | |
| } else { | |
| setName(''); | |
| setClient(''); | |
| setDueDate(''); | |
| setDescription(''); | |
| setStep('select'); | |
| } | |
| }, [project]); | |
| // Fetch opportunities when entering opportunities step | |
| useEffect(() => { | |
| if (step === 'opportunities' && opportunities.length === 0) { | |
| fetchOpportunities(); | |
| } | |
| }, [step]); | |
| const fetchOpportunities = async () => { | |
| setLoadingOpps(true); | |
| try { | |
| // Fetch saved opportunities from the database | |
| const result = await api.opportunities.getSaved({ limit: 100 }); | |
| // Map saved opportunities to display format | |
| const displayOpps: DisplayOpportunity[] = (result.opportunities || []).map((opp: SavedOpportunity) => ({ | |
| id: opp.id, | |
| title: opp.title, | |
| department: opp.department || 'Unknown', | |
| description: opp.description, | |
| response_deadline: opp.response_deadline, | |
| source: opp.province || opp.source_key || 'Database', | |
| url: opp.url, | |
| })); | |
| setOpportunities(displayOpps); | |
| } catch (err) { | |
| console.error('Failed to fetch opportunities:', err); | |
| setOpportunities([]); | |
| } finally { | |
| setLoadingOpps(false); | |
| } | |
| }; | |
| const handleSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| const projectData: CreateProjectData = { | |
| name, | |
| client, | |
| due_date: dueDate, | |
| description, | |
| }; | |
| onSave(projectData, project?.id); | |
| }; | |
| const handleCreateFromOpportunity = () => { | |
| if (!selectedOpp) return; | |
| const projectData: CreateProjectData = { | |
| name: selectedOpp.title, | |
| client: selectedOpp.department || '', | |
| due_date: selectedOpp.response_deadline || '', | |
| description: selectedOpp.description || '', | |
| }; | |
| onSave(projectData); | |
| }; | |
| const handleAutoRFPClick = () => { | |
| setStep('opportunities'); | |
| }; | |
| const handleManualClick = () => { | |
| setStep('manual'); | |
| }; | |
| const formatDate = (dateString?: string) => { | |
| if (!dateString) return '-'; | |
| try { | |
| return new Date(dateString).toLocaleDateString('en-GB', { | |
| day: 'numeric', | |
| month: 'short', | |
| year: 'numeric' | |
| }); | |
| } catch { | |
| return dateString; | |
| } | |
| }; | |
| // Filter opportunities by search | |
| const filteredOpportunities = opportunities.filter(opp => { | |
| if (!searchQuery.trim()) return true; | |
| const query = searchQuery.toLowerCase(); | |
| return ( | |
| opp.title.toLowerCase().includes(query) || | |
| opp.department.toLowerCase().includes(query) || | |
| opp.description?.toLowerCase().includes(query) | |
| ); | |
| }); | |
| // Selection Step - Choose how to create workspace | |
| if (step === 'select') { | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> | |
| {/* Backdrop */} | |
| <div | |
| className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" | |
| onClick={onClose} | |
| /> | |
| {/* Modal */} | |
| <div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl transform transition-all p-6"> | |
| {/* Header */} | |
| <div className="flex justify-between items-center mb-2"> | |
| <h2 className="text-xl font-bold text-slate-900 dark:text-white">Create New Workspace</h2> | |
| <button | |
| onClick={onClose} | |
| className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors rounded-full p-1 hover:bg-slate-100 dark:hover:bg-slate-700" | |
| > | |
| <span className="material-symbols-outlined">close</span> | |
| </button> | |
| </div> | |
| {/* Content */} | |
| <div className="mt-4"> | |
| <p className="text-center text-slate-600 dark:text-slate-400 mb-8"> | |
| How would you like to start your workspace? | |
| </p> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| {/* Auto RFP Opportunities Option */} | |
| <button | |
| onClick={handleAutoRFPClick} | |
| className="group relative flex flex-col items-start p-6 border border-slate-200 dark:border-slate-600 rounded-xl hover:border-blue-500 dark:hover:border-blue-500 hover:shadow-md transition-all text-left bg-white dark:bg-slate-700" | |
| > | |
| <div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-600 text-slate-900 dark:text-white mb-4 group-hover:bg-blue-50 dark:group-hover:bg-blue-900/30 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"> | |
| <span className="material-symbols-outlined text-2xl">search</span> | |
| </div> | |
| <h3 className="text-base font-semibold text-slate-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400"> | |
| Auto RFP Opportunities | |
| </h3> | |
| <p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed"> | |
| Select from our database of public sector tenders and RFPs | |
| </p> | |
| </button> | |
| {/* Manual Entry Option */} | |
| <button | |
| onClick={handleManualClick} | |
| className="group relative flex flex-col items-start p-6 border border-slate-200 dark:border-slate-600 rounded-xl hover:border-blue-500 dark:hover:border-blue-500 hover:shadow-md transition-all text-left bg-white dark:bg-slate-700" | |
| > | |
| <div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-600 text-slate-900 dark:text-white mb-4 group-hover:bg-blue-50 dark:group-hover:bg-blue-900/30 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"> | |
| <span className="material-symbols-outlined text-2xl">description</span> | |
| </div> | |
| <h3 className="text-base font-semibold text-slate-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400"> | |
| My Own Opportunity | |
| </h3> | |
| <p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed"> | |
| Manually enter details for a private or external opportunity | |
| </p> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Opportunities Selection Step | |
| if (step === 'opportunities') { | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> | |
| {/* Backdrop */} | |
| <div | |
| className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" | |
| onClick={onClose} | |
| /> | |
| {/* Modal */} | |
| <div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl transform transition-all flex flex-col max-h-[90vh]"> | |
| {/* Header */} | |
| <div className="px-6 py-5 flex items-center justify-between border-b border-slate-100 dark:border-slate-700/50"> | |
| <button | |
| onClick={() => setStep('select')} | |
| className="flex items-center gap-2 text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-white cursor-pointer transition" | |
| > | |
| <span className="material-symbols-outlined text-sm">arrow_back</span> | |
| <span className="text-sm font-medium">Back</span> | |
| </button> | |
| <h2 className="text-lg font-semibold text-slate-900 dark:text-white">Select Opportunity</h2> | |
| <button | |
| onClick={onClose} | |
| className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition rounded-full p-1 hover:bg-slate-100 dark:hover:bg-slate-700" | |
| > | |
| <span className="material-symbols-outlined">close</span> | |
| </button> | |
| </div> | |
| {/* Content */} | |
| <div className="p-6 flex-1 overflow-hidden flex flex-col"> | |
| {/* Search */} | |
| <div className="relative mb-5"> | |
| <span className="absolute inset-y-0 left-3 flex items-center text-slate-400"> | |
| <span className="material-symbols-outlined">search</span> | |
| </span> | |
| <input | |
| type="text" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition shadow-sm placeholder-slate-400 dark:placeholder-slate-500" | |
| placeholder="Search tenders by title or buyer..." | |
| /> | |
| </div> | |
| {/* Opportunities List */} | |
| <div className="overflow-y-auto flex-1 -mr-2 pr-2 space-y-3 custom-scrollbar"> | |
| {loadingOpps ? ( | |
| <div className="flex items-center justify-center py-12"> | |
| <div className="flex flex-col items-center gap-4"> | |
| <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div> | |
| <p className="text-slate-500 dark:text-slate-400 text-sm">Loading opportunities...</p> | |
| </div> | |
| </div> | |
| ) : filteredOpportunities.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-12"> | |
| <div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mb-3"> | |
| <span className="material-symbols-outlined text-2xl text-slate-400">search_off</span> | |
| </div> | |
| <p className="text-slate-500 dark:text-slate-400 text-sm">No opportunities found</p> | |
| </div> | |
| ) : ( | |
| filteredOpportunities.map((opp) => ( | |
| <div | |
| key={opp.id} | |
| onClick={() => setSelectedOpp(opp)} | |
| className={`border rounded-lg p-4 cursor-pointer transition bg-white dark:bg-slate-700 group shadow-sm ${ | |
| selectedOpp?.id === opp.id | |
| ? 'border-blue-500 ring-2 ring-blue-500/20' | |
| : 'border-slate-200 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-400' | |
| }`} | |
| > | |
| <h3 className={`font-medium text-base mb-2 transition-colors line-clamp-1 ${ | |
| selectedOpp?.id === opp.id | |
| ? 'text-blue-600 dark:text-blue-400' | |
| : 'text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400' | |
| }`}> | |
| {opp.title} | |
| </h3> | |
| <div className="flex flex-wrap gap-y-2 gap-x-4 text-xs text-slate-500 dark:text-slate-400 items-center"> | |
| <div className="flex items-center gap-1.5"> | |
| <span className="material-symbols-outlined text-base">business</span> | |
| <span className="truncate max-w-[180px]">{opp.department}</span> | |
| </div> | |
| <div className="flex items-center gap-1.5"> | |
| <span className="material-symbols-outlined text-base">place</span> | |
| <span>{opp.source || 'Database'}</span> | |
| </div> | |
| {opp.response_deadline && ( | |
| <div className="flex items-center gap-1.5"> | |
| <span className="material-symbols-outlined text-base">calendar_today</span> | |
| <span>{formatDate(opp.response_deadline)}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* Footer */} | |
| <div className="px-6 py-4 border-t border-slate-100 dark:border-slate-700/50 flex justify-end gap-3 bg-slate-50 dark:bg-slate-800/50 rounded-b-xl"> | |
| <button | |
| onClick={onClose} | |
| className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition shadow-sm" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={handleCreateFromOpportunity} | |
| disabled={!selectedOpp} | |
| className={`px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm transition ${ | |
| selectedOpp | |
| ? 'bg-blue-900 hover:bg-blue-800' | |
| : 'bg-blue-400 dark:bg-blue-500 cursor-not-allowed opacity-90' | |
| }`} | |
| > | |
| Create Workspace | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Manual Entry Form Step | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> | |
| {/* Backdrop */} | |
| <div | |
| className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" | |
| onClick={onClose} | |
| /> | |
| {/* Modal */} | |
| <div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg transform transition-all"> | |
| {/* Header */} | |
| <div className="px-6 py-5 flex items-center justify-between border-b border-slate-100 dark:border-slate-700/50"> | |
| {!project ? ( | |
| <button | |
| onClick={() => setStep('select')} | |
| className="flex items-center gap-2 text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-white cursor-pointer transition" | |
| > | |
| <span className="material-symbols-outlined text-sm">arrow_back</span> | |
| <span className="text-sm font-medium">Back</span> | |
| </button> | |
| ) : ( | |
| <div /> | |
| )} | |
| <h2 className="text-lg font-semibold text-slate-900 dark:text-white"> | |
| {project ? 'Edit Workspace' : 'Enter Opportunity Details'} | |
| </h2> | |
| <button | |
| onClick={onClose} | |
| className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition rounded-full p-1 hover:bg-slate-100 dark:hover:bg-slate-700" | |
| > | |
| <span className="material-symbols-outlined">close</span> | |
| </button> | |
| </div> | |
| {/* Form */} | |
| <form onSubmit={handleSubmit}> | |
| <div className="p-6 space-y-5"> | |
| {/* Project Title */} | |
| <div> | |
| <label htmlFor="name" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> | |
| Project Title <span className="text-rose-500">*</span> | |
| </label> | |
| <div className="relative"> | |
| <span className="absolute inset-y-0 left-3 flex items-center text-slate-400"> | |
| <span className="material-symbols-outlined text-xl">description</span> | |
| </span> | |
| <input | |
| type="text" | |
| id="name" | |
| value={name} | |
| onChange={(e) => setName(e.target.value)} | |
| placeholder="e.g. Website Redesign Project" | |
| className="block w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" | |
| required | |
| /> | |
| </div> | |
| </div> | |
| {/* Client / Buyer */} | |
| <div> | |
| <label htmlFor="client" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> | |
| Client / Buyer <span className="text-rose-500">*</span> | |
| </label> | |
| <div className="relative"> | |
| <span className="absolute inset-y-0 left-3 flex items-center text-slate-400"> | |
| <span className="material-symbols-outlined text-xl">business</span> | |
| </span> | |
| <input | |
| type="text" | |
| id="client" | |
| value={client} | |
| onChange={(e) => setClient(e.target.value)} | |
| placeholder="e.g. Acme Corporation" | |
| className="block w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" | |
| required | |
| /> | |
| </div> | |
| </div> | |
| {/* Deadline */} | |
| <div> | |
| <label htmlFor="dueDate" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> | |
| Deadline | |
| </label> | |
| <div className="relative"> | |
| <span className="absolute inset-y-0 left-3 flex items-center text-slate-400"> | |
| <span className="material-symbols-outlined text-xl">calendar_today</span> | |
| </span> | |
| <input | |
| type="date" | |
| id="dueDate" | |
| value={dueDate} | |
| onChange={(e) => setDueDate(e.target.value)} | |
| className="block w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" | |
| /> | |
| </div> | |
| </div> | |
| {/* Description / Notes */} | |
| <div> | |
| <label htmlFor="description" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> | |
| Description / Notes | |
| </label> | |
| <textarea | |
| id="description" | |
| value={description} | |
| onChange={(e) => setDescription(e.target.value)} | |
| rows={3} | |
| placeholder="Add any relevant details about this opportunity..." | |
| className="block w-full px-4 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors resize-none" | |
| /> | |
| </div> | |
| </div> | |
| {/* Footer */} | |
| <div className="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-slate-700/50 bg-slate-50 dark:bg-slate-800/50 rounded-b-xl"> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition shadow-sm" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| type="submit" | |
| disabled={!name.trim() || !client.trim()} | |
| className={`px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm transition ${ | |
| name.trim() && client.trim() | |
| ? 'bg-blue-600 hover:bg-blue-700' | |
| : 'bg-slate-400 cursor-not-allowed' | |
| }`} | |
| > | |
| {project ? 'Save Changes' : 'Create Workspace'} | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ProjectModal; | |