← back to Grant
components/grants/ProposalModal.tsx
630 lines
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
X,
Loader2,
Copy,
ExternalLink,
Sparkles,
FileText,
Pencil,
Check,
Send,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
interface Grant {
id: string;
title: string;
funder: string;
status: string;
[key: string]: unknown;
}
interface Proposal {
id: string;
subject: string;
body_html: string;
body_text: string;
proposal_type: string;
status: string;
version: number;
created_at: string;
}
const TYPE_LABELS: Record<string, string> = {
letter_of_inquiry: 'Letter of Inquiry',
full_proposal: 'Full Proposal',
one_pager: 'One-Pager',
meeting_request: 'Meeting Request',
};
function formatDate(d: string | null): string {
if (!d) return '';
return new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
interface ProposalModalProps {
grant: Grant;
onClose: () => void;
onStatusChange: (id: string, status: string) => void;
}
export default function ProposalModal({
grant,
onClose,
onStatusChange,
}: ProposalModalProps) {
const { addToast } = useToast();
const { canUseAI } = useAuth();
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Store previously focused element and focus the modal on mount
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus the modal container on mount
modalRef.current?.focus();
return () => {
// Restore focus on unmount
previousFocusRef.current?.focus();
};
}, []);
// Escape to close + focus trapping
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab' && modalRef.current) {
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}, [onClose]);
const [proposalType, setProposalType] = useState('letter_of_inquiry');
const [context, setContext] = useState('');
const [generating, setGenerating] = useState(false);
const [proposal, setProposal] = useState<Proposal | null>(null);
const [copied, setCopied] = useState(false);
const [existingProposals, setExistingProposals] = useState<Proposal[]>([]);
const [loadingExisting, setLoadingExisting] = useState(true);
// Editing state
const [isEditing, setIsEditing] = useState(false);
const [editBody, setEditBody] = useState('');
// Send & Submit confirmation flow
const [showSendConfirm, setShowSendConfirm] = useState(false);
// Submitting state
const [submitting, setSubmitting] = useState(false);
// Load existing proposals
useEffect(() => {
(async () => {
try {
const res = await fetch(`/api/grants/${grant.id}/proposals`, {
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
setExistingProposals(data.proposals || []);
}
} catch {
// fetch error silently handled
} finally {
setLoadingExisting(false);
}
})();
}, [grant.id]);
const handleGenerate = async () => {
setGenerating(true);
try {
const res = await fetch(`/api/grants/${grant.id}/proposals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ proposal_type: proposalType, context }),
});
if (res.ok) {
const data = await res.json();
setProposal(data);
setExistingProposals((prev) => [data, ...prev]);
addToast('Proposal generated', 'success');
} else {
addToast('Proposal generation failed', 'error');
}
} catch {
addToast('Proposal generation failed', 'error');
} finally {
setGenerating(false);
}
};
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
addToast('Copied to clipboard', 'success');
} catch {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
addToast('Copied to clipboard', 'success');
}
};
const handleOpenEmail = (subject: string, bodyText: string) => {
const mailtoUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyText)}`;
window.open(mailtoUrl, '_blank');
};
const handleStartEdit = () => {
if (!proposal) return;
setEditBody(proposal.body_text || stripHtml(proposal.body_html));
setIsEditing(true);
};
const handleSaveEdit = () => {
if (!proposal) return;
// Update local state (PATCH route may not exist yet)
setProposal({ ...proposal, body_text: editBody });
// Also update in existing proposals list
setExistingProposals((prev) =>
prev.map((p) =>
p.id === proposal.id ? { ...p, body_text: editBody } : p,
),
);
setIsEditing(false);
addToast('Proposal updated', 'success');
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditBody('');
};
const handleMarkSubmitted = async () => {
setSubmitting(true);
try {
const res = await fetch('/api/grants', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
id: grant.id,
status: 'submitted',
applied_at: new Date().toISOString(),
}),
});
if (res.ok) {
addToast('Grant marked as submitted', 'success');
onStatusChange(grant.id, 'submitted');
onClose();
} else {
addToast('Failed to update status', 'error');
}
} catch {
addToast('Failed to update status', 'error');
} finally {
setSubmitting(false);
}
};
const handleSendAndSubmit = (subject: string, bodyText: string) => {
handleOpenEmail(subject, bodyText);
setShowSendConfirm(true);
};
const canSubmit =
grant.status !== 'submitted' && grant.status !== 'awarded';
const bodyText = proposal
? proposal.body_text || stripHtml(proposal.body_html)
: '';
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="proposal-modal-title"
onKeyDown={handleKeyDown}
>
<div
ref={modalRef}
className="card w-full max-w-2xl max-h-[90vh] overflow-auto"
style={{ backgroundColor: 'var(--color-surface)' }}
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h3
id="proposal-modal-title"
className="text-base font-semibold"
style={{ color: 'var(--color-text)' }}
>
Write Proposal
</h3>
<p
className="text-xs mt-0.5"
style={{ color: 'var(--color-text-muted)' }}
>
{grant.title}
</p>
</div>
<button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
<X size={16} />
</button>
</div>
{/* Generate Form */}
{!proposal && (
<div className="space-y-3">
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Proposal Type
</label>
<select
className="input"
value={proposalType}
onChange={(e) => setProposalType(e.target.value)}
>
{Object.entries(TYPE_LABELS).map(([val, label]) => (
<option key={val} value={val}>
{label}
</option>
))}
</select>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Additional Context (optional)
</label>
<textarea
className="input"
rows={3}
placeholder="Any specific points to include, recent accomplishments, specific programs to highlight..."
value={context}
onChange={(e) => setContext(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
{canUseAI ? (
<button
className="btn btn-primary w-full"
onClick={handleGenerate}
disabled={generating}
>
{generating ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Sparkles size={15} />
)}
{generating ? 'Generating with AI...' : 'Generate Proposal'}
</button>
) : (
<p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
AI proposal drafting isn’t available for this account.
</p>
)}
</div>
)}
{/* Generated Proposal */}
{proposal && (
<div className="space-y-3">
{/* Subject */}
<div
className="p-3 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<div className="flex items-center justify-between mb-2">
<h4
className="text-xs font-semibold"
style={{ color: 'var(--color-text-secondary)' }}
>
Subject
</h4>
<span className="badge badge-primary">
{TYPE_LABELS[proposal.proposal_type] ||
proposal.proposal_type}
</span>
</div>
<p
className="text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
{proposal.subject}
</p>
</div>
{/* Body — read-only or editable */}
<div
className="p-4 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<div className="flex items-center justify-between mb-2">
<h4
className="text-xs font-semibold"
style={{ color: 'var(--color-text-secondary)' }}
>
Body
</h4>
{!isEditing && (
<button
className="btn btn-ghost btn-sm"
onClick={handleStartEdit}
title="Edit proposal body"
>
<Pencil size={13} />
<span className="text-xs">Edit</span>
</button>
)}
</div>
{isEditing ? (
<div>
<textarea
className="input"
rows={12}
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
aria-label="Edit proposal body"
style={{
resize: 'vertical',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
/>
<div className="flex gap-2 mt-2">
<button
className="btn btn-primary btn-sm"
onClick={handleSaveEdit}
>
<Check size={13} />
Save
</button>
<button
className="btn btn-secondary btn-sm"
onClick={handleCancelEdit}
>
Cancel
</button>
</div>
</div>
) : (
<div
style={{
color: 'var(--color-text-secondary)',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
>
{bodyText
.split('\n')
.map((line: string, i: number) => (
<p
key={i}
style={{
marginBottom: line.trim() ? '0.5rem' : '0.25rem',
}}
>
{line || '\u00A0'}
</p>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex gap-2">
<button
className="btn btn-secondary flex-1"
onClick={() => handleCopy(bodyText)}
>
<Copy size={14} />
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
<button
className="btn btn-primary flex-1"
onClick={() =>
handleSendAndSubmit(proposal.subject, bodyText)
}
>
<Send size={14} />
Send & Submit
</button>
</div>
{/* Send confirmation bar */}
{showSendConfirm && (
<div
className="p-3 rounded-lg flex items-center justify-between"
style={{
backgroundColor: 'rgba(5,150,105,0.08)',
border: '1px solid rgba(5,150,105,0.2)',
}}
>
<span
className="text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Did you send the email?
</span>
<div className="flex gap-2">
<button
className="btn btn-sm"
style={{
backgroundColor: '#059669',
color: '#fff',
border: '1px solid #059669',
}}
onClick={handleMarkSubmitted}
disabled={submitting}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Check size={12} />
)}
Yes, Mark Submitted
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowSendConfirm(false)}
>
Not Yet
</button>
</div>
</div>
)}
{/* Mark as Submitted standalone */}
{canSubmit && !showSendConfirm && (
<button
className="btn w-full"
style={{
backgroundColor: '#059669',
color: '#fff',
border: '1px solid #059669',
}}
onClick={handleMarkSubmitted}
disabled={submitting}
>
{submitting ? (
<Loader2 size={15} className="animate-spin" />
) : (
<FileText size={15} />
)}
{submitting ? 'Updating...' : 'Mark as Submitted'}
</button>
)}
<button
className="btn btn-ghost btn-sm w-full"
onClick={() => {
setProposal(null);
setIsEditing(false);
setShowSendConfirm(false);
}}
>
Generate Another
</button>
</div>
)}
{/* Existing Proposals */}
{existingProposals.length > 0 && !proposal && (
<div
className="mt-4 pt-4"
style={{ borderTop: '1px solid var(--color-border)' }}
>
<h4
className="text-xs font-semibold mb-2"
style={{ color: 'var(--color-text-secondary)' }}
>
Previous Proposals ({existingProposals.length})
</h4>
<div className="space-y-2">
{existingProposals.map((p) => (
<button
key={p.id}
className="w-full text-left p-2 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
onClick={() => setProposal(p)}
>
<div className="flex items-center justify-between">
<span
className="text-xs font-medium truncate"
style={{ color: 'var(--color-text)' }}
>
{p.subject}
</span>
<span
className="badge badge-info"
style={{ fontSize: '0.625rem' }}
>
v{p.version}
</span>
</div>
<span
className="text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
{TYPE_LABELS[p.proposal_type] || p.proposal_type} -{' '}
{formatDate(p.created_at)}
</span>
</button>
))}
</div>
</div>
)}
{loadingExisting && !proposal && (
<div className="flex justify-center py-4">
<Loader2
size={16}
className="animate-spin"
style={{ color: 'var(--color-text-muted)' }}
/>
</div>
)}
</div>
</div>
);
}