contravaulthvnkz / components /ProjectModal.tsx
jackmichael's picture
feat: Add Inngest integration, URL document ingestion, auto-project creation, and UI fixes
1f27f04
'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;