← 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>
  );
}