diff --git a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx new file mode 100644 index 00000000..009e123e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { X, Loader2, Send } from 'lucide-react'; +import { toast } from 'sonner'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { useClient } from 'hooks/useClient'; +import { useGlobal } from 'hooks/useGlobal'; + +export type ComposeDraft = { to?: string; cc?: string; subject?: string; body?: string; inReplyTo?: string }; + +// Shared open/close handle. Set a draft to open the composer; null closes it. +export const useComposer = () => useGlobal('EMAIL_COMPOSE', null); + +type Contact = { address: string; name: string }; + +// A recipient field with contact autocomplete on the last comma-separated segment. +const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => { + const client = useClient(); + const [suggestions, setSuggestions] = useState([]); + const [open, setOpen] = useState(false); + const seg = value.split(',').pop()?.trim() ?? ''; + + useEffect(() => { + if (seg.length < 1) { + setSuggestions([]); + return; + } + const t = setTimeout(() => { + client.get(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {}); + }, 180); + return () => clearTimeout(t); + }, [seg]); + + const pick = (address: string) => { + const segs = value.split(','); + segs[segs.length - 1] = ` ${address}`; + onChange(segs.join(',').replace(/^\s+/, '') + ', '); + setOpen(false); + }; + + return ( +
+ { + onChange(ev.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 150)} + placeholder={placeholder} + className="w-full bg-transparent px-3 py-2 text-sm outline-none placeholder:opacity-40" + /> + {open && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+ ); +}; + +export const ComposeModal = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const [draft, setDraft] = useComposer(); + const [to, setTo] = useState(''); + const [cc, setCc] = useState(''); + const [showCc, setShowCc] = useState(false); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + const [sending, setSending] = useState(false); + const seeded = useRef(null); + + useEffect(() => { + if (draft && draft !== seeded.current) { + seeded.current = draft; + setTo(draft.to ?? ''); + setCc(draft.cc ?? ''); + setShowCc(!!draft.cc); + setSubject(draft.subject ?? ''); + setBody(draft.body ?? ''); + } + if (!draft) seeded.current = null; + }, [draft]); + + const close = () => setDraft(null); + + const send = async () => { + if (!to.trim()) { + toast.error('Add at least one recipient'); + return; + } + setSending(true); + try { + await client.post('/email/send', { to, cc: cc || undefined, subject, body, inReplyTo: draft?.inReplyTo }); + toast.success('Email sent'); + // Gmail files the sent copy in Sent, so refresh the list to surface it. + queryClient.invalidateQueries({ queryKey: ['email-messages'] }); + close(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to send'); + } finally { + setSending(false); + } + }; + + if (!draft) return null; + + return ( + !o && close()}> + +
+ {/^re:/i.test(subject) ? 'Reply' : 'New message'} + +
+ +
+ To +
+ +
+ {!showCc && ( + + )} +
+ {showCc && ( +
+ Cc +
+ +
+
+ )} + setSubject(ev.target.value)} + placeholder="Subject" + className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40" + /> +