Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
200
src/components/email/email-draft-button.tsx
Normal file
200
src/components/email/email-draft-button.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Loader2, Mail, Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EmailDraftButtonProps {
|
||||
interestId: string;
|
||||
clientId: string;
|
||||
context?: 'follow_up' | 'introduction' | 'stage_update' | 'general';
|
||||
additionalInstructions?: string;
|
||||
}
|
||||
|
||||
interface DraftResult {
|
||||
subject: string;
|
||||
body: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Polling helper ───────────────────────────────────────────────────────────
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const POLL_MAX_ATTEMPTS = 20; // 30 s total
|
||||
|
||||
async function pollDraftResult(jobId: string): Promise<DraftResult> {
|
||||
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
|
||||
const res = await apiFetch<{ status: string; data?: DraftResult }>(
|
||||
`/api/v1/ai/email-draft/${jobId}`,
|
||||
);
|
||||
|
||||
if (res.status === 'complete' && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
throw new Error('Email draft generation timed out. Please try again.');
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function EmailDraftButton({
|
||||
interestId,
|
||||
clientId,
|
||||
context = 'follow_up',
|
||||
additionalInstructions,
|
||||
}: EmailDraftButtonProps) {
|
||||
const featureEnabled = useFeatureFlag('ai_email_drafts');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<DraftResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleGenerateDraft = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setDraft(null);
|
||||
|
||||
try {
|
||||
const { jobId } = await apiFetch<{ jobId: string }>('/api/v1/ai/email-draft', {
|
||||
method: 'POST',
|
||||
body: { interestId, clientId, context, additionalInstructions },
|
||||
});
|
||||
|
||||
const result = await pollDraftResult(jobId);
|
||||
setDraft(result);
|
||||
setSheetOpen(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate draft');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [interestId, clientId, context, additionalInstructions]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!draft) return;
|
||||
const text = `Subject: ${draft.subject}\n\n${draft.body}`;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [draft]);
|
||||
|
||||
if (!featureEnabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateDraft}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="mr-1.5 h-3.5 w-3.5" />
|
||||
Draft Email
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||
)}
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent side="right" className="w-full max-w-lg flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Generated Email Draft</SheetTitle>
|
||||
<SheetDescription>
|
||||
Review and edit the draft before sending.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{draft && (
|
||||
<div className="flex-1 overflow-y-auto mt-4 space-y-4">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Subject
|
||||
</label>
|
||||
<p
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="mt-1 text-sm font-medium border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{draft.subject}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Body
|
||||
</label>
|
||||
<pre
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="mt-1 text-sm whitespace-pre-wrap font-sans border rounded-md px-3 py-2 min-h-[300px] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{draft.body}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-1.5 h-3.5 w-3.5 text-green-600" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
Copy to clipboard
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateDraft}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
'Regenerate'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user