From 0dbf863f60b919560b766f78b107ebac9612bd9d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:00:43 +0200 Subject: [PATCH] feat(messages): create/select quote, contract, invoice, gallery from a message The toolbar doc buttons now open a real document-action flow instead of just loading an email template: - DocumentActionModal resolves the customer from the message's sender address (customers/search); if no match, the CustomerPicker lets you search or create a passive customer inline. - Create new -> jumps to the real editor prefilled with the customer (quotes/contracts/bills ?customerAccountId=), so numbering, line items and PDF all come from the existing CRM. Gallery opens the event editor. - Select existing -> lists that customer's quotes/contracts/invoices and drops the chosen document number into a reply composer. - Toolbar buttons are gated by the global feature flags (quotes/contracts/bills). Adds the missing customerAccountId prefill to ContractEditorPage (quotes + bills already had it). Frontend-only; reuses existing endpoints. Build verified. --- .../admin/contracts/ContractEditorPage.tsx | 26 ++- .../admin/messages/DocumentActionModal.tsx | 167 ++++++++++++++++++ .../src/pages/admin/messages/MessagesPage.tsx | 57 +++--- 3 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 frontend/src/pages/admin/messages/DocumentActionModal.tsx diff --git a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx index ad9daa5e..b0615ea3 100644 --- a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx +++ b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx @@ -13,7 +13,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams, Link } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams, Link } from 'react-router-dom'; import { useQuery, useMutation } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { ArrowLeft, Eye, Save } from 'lucide-react'; @@ -25,6 +25,7 @@ import { } from '../../../services/contracts.service'; import { CustomerPicker } from '../../../components/admin/CustomerPicker'; import { ProjectSelect } from '../../../components/admin/ProjectSelect'; +import { customerAdminService } from '../../../services/customerAdmin.service'; interface BlockRow { blockId: number; @@ -39,6 +40,7 @@ interface BlockRow { export const ContractEditorPage: React.FC = () => { const { t } = useTranslation(); const { id } = useParams<{ id?: string }>(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const isEdit = Boolean(id); const numericId = id ? parseInt(id, 10) : null; @@ -67,6 +69,28 @@ export const ContractEditorPage: React.FC = () => { const [projectId, setProjectId] = useState(null); const [blocks, setBlocks] = useState([]); + // Prefill the customer when opened as "new contract for this customer" + // (?customerAccountId=42), e.g. from the Messages view. New contracts only; + // mirrors QuoteEditorPage / BillEditorPage. + useEffect(() => { + if (isEdit || customerAccountId) return; + const raw = searchParams.get('customerAccountId'); + const cid = raw ? parseInt(raw, 10) : NaN; + if (!Number.isFinite(cid) || cid <= 0) return; + let cancelled = false; + (async () => { + try { + const c = await customerAdminService.get(cid); + if (cancelled) return; + setCustomerAccountId(c.id); + setCustomerLabel(c.companyName || c.displayName || [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email); + setCustomerIsPassive(Boolean(c.isPassive)); + if (c.preferredLanguage) setLanguage(c.preferredLanguage); + } catch { /* ignore — admin can still pick manually */ } + })(); + return () => { cancelled = true; }; + }, [isEdit, searchParams, customerAccountId]); + // Load existing contract on edit. const { data: existing, isLoading: existingLoading } = useQuery({ queryKey: ['contract', numericId], diff --git a/frontend/src/pages/admin/messages/DocumentActionModal.tsx b/frontend/src/pages/admin/messages/DocumentActionModal.tsx new file mode 100644 index 00000000..86173994 --- /dev/null +++ b/frontend/src/pages/admin/messages/DocumentActionModal.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { X, Plus, FileText } from 'lucide-react'; +import { Button, Loading } from '../../../components/common'; +import { CustomerPicker } from '../../../components/admin/CustomerPicker'; +import { customerAdminService } from '../../../services/customerAdmin.service'; +import { quotesService } from '../../../services/quotes.service'; +import { contractsService } from '../../../services/contracts.service'; +import { billsService } from '../../../services/bills.service'; + +/** + * From a customer message: resolve (or pick/create) the customer, then either + * create a NEW document of the given type (jumps to the real editor prefilled + * with the customer) or SELECT an existing one to reference in a reply. Reuses + * the CRM editors, list endpoints and CustomerPicker — no duplicated doc logic. + */ +export type DocType = 'quote' | 'contract' | 'invoice' | 'gallery'; + +const CONFIG: Record = { + quote: { label: 'Quote', newRoute: '/admin/clients/quotes/new', hasExisting: true }, + contract: { label: 'Contract', newRoute: '/admin/clients/contracts/new', hasExisting: true }, + invoice: { label: 'Invoice', newRoute: '/admin/clients/bills/new', hasExisting: true }, + gallery: { label: 'Gallery', newRoute: '/admin/events/new', hasExisting: false }, +}; + +interface DocRow { id: number; number: string; status: string } + +type SelCustomer = { id: number; email: string; label: string }; + +export const DocumentActionModal: React.FC<{ + docType: DocType; + senderEmail: string; + onCompose: (init: { to: string; subject: string; html: string }) => void; + onClose: () => void; + t: (k: string, d?: string) => string; +}> = ({ docType, senderEmail, onCompose, onClose, t }) => { + const navigate = useNavigate(); + const cfg = CONFIG[docType]; + const [customer, setCustomer] = useState(null); + const [resolving, setResolving] = useState(true); + + const pick = (c: { id: number; email: string; displayName?: string | null; companyName?: string | null }) => + setCustomer({ id: c.id, email: c.email, label: c.companyName || c.displayName || c.email }); + + // Resolve the customer from the message's sender address (first match). + useEffect(() => { + let cancelled = false; + setResolving(true); + customerAdminService.search(senderEmail) + .then((rows) => { if (!cancelled && rows.length) pick(rows[0]); }) + .catch(() => {}) + .finally(() => { if (!cancelled) setResolving(false); }); + return () => { cancelled = true; }; + }, [senderEmail]); + + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', h); + return () => window.removeEventListener('keydown', h); + }, [onClose]); + + const existing = useQuery({ + queryKey: ['messages', 'docs', docType, customer?.id], + enabled: !!customer && cfg.hasExisting, + queryFn: async (): Promise => { + const customerAccountId = customer!.id; + if (docType === 'quote') { + const r = await quotesService.list({ customerAccountId, page: 1, pageSize: 20 }); + return r.quotes.map((q) => ({ id: q.id, number: q.quoteNumber, status: q.status })); + } + if (docType === 'contract') { + const r = await contractsService.list({ customerAccountId, page: 1, pageSize: 20 }); + return r.contracts.map((c) => ({ id: c.id, number: c.contractNumber, status: c.status })); + } + const r = await billsService.list({ customerAccountId, page: 1, pageSize: 20 }); + return r.invoices.map((i) => ({ id: i.id, number: i.invoiceNumber, status: i.status })); + }, + }); + + const createNew = () => { + if (!customer && docType !== 'gallery') return; + navigate(docType === 'gallery' || !customer ? cfg.newRoute : `${cfg.newRoute}?customerAccountId=${customer.id}`); + onClose(); + }; + + const pickExisting = (d: DocRow) => { + const html = `


${cfg.label} ${d.number}


`; + onCompose({ to: customer?.email || senderEmail, subject: `${cfg.label} ${d.number}`, html }); + onClose(); + }; + + return ( +
+
e.stopPropagation()}> +
+ + {t(`messages.doc.${docType}`, cfg.label)} + + +
+ +
+
+ setCustomer(null)} + /> + {resolving &&

{t('messages.resolvingCustomer', 'Matching the sender to a customer…')}

} + {!resolving && !customer && ( +

+ {t('messages.noCustomerMatch', 'No customer matched this sender — search for one or create a new customer above.')} +

+ )} +
+ + {customer && ( + <> + + + {cfg.hasExisting && ( +
+
+ {t('messages.existingDocs', 'Or reference an existing one')} +
+ {existing.isLoading ? ( + + ) : (existing.data && existing.data.length > 0) ? ( +
+ {existing.data.map((d) => ( + + ))} +
+ ) : ( +

{t('messages.noExistingDocs', 'No existing documents for this customer yet.')}

+ )} +
+ )} + {!cfg.hasExisting && ( +

+ {t('messages.galleryCreateOnly', 'Galleries are event-based — this opens the event editor, where you can assign the customer.')} +

+ )} + + )} +
+
+
+ ); +}; + +export default DocumentActionModal; diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx index fb9ff872..fdbfbf1c 100644 --- a/frontend/src/pages/admin/messages/MessagesPage.tsx +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -12,6 +12,8 @@ import { emailService, type ReceivedEmail, type MailIdentities } from '../../../ import { accountingService } from '../../../services/accounting.service'; import { Loading } from '../../../components/common'; import { MessageComposer, type ComposerInit } from './MessageComposer'; +import { DocumentActionModal, type DocType } from './DocumentActionModal'; +import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; /** * Admin "Messages" — read-only viewer over the mail picpeak already @@ -63,12 +65,14 @@ const STATUS_STYLES: Record = { }; export const MessagesPage: React.FC = () => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const navigate = useNavigate(); const [activeFolder, setActiveFolder] = useState('auto-sent'); const [selection, setSelection] = useState(null); const [pdfDocId, setPdfDocId] = useState(null); const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null); + const [docAction, setDocAction] = useState<{ docType: DocType; senderEmail: string } | null>(null); + const { flags } = useFeatureFlags(); // "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop. const sync = useMutation({ @@ -265,11 +269,12 @@ export const MessagesPage: React.FC = () => { navigate('/admin/accounting/inbox')} onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })} + onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })} t={t} /> @@ -286,6 +291,15 @@ export const MessagesPage: React.FC = () => { t={t} /> )} + {docAction && ( + { setDocAction(null); setComposer({ init: { to: init.to, subject: init.subject, html: init.html }, title: init.subject, accountKey: 'customers' }); }} + onClose={() => setDocAction(null)} + t={t} + /> + )} ); }; @@ -375,13 +389,14 @@ const MessageList: React.FC<{ const ReadingPane: React.FC<{ selection: Selection; account: Account; - lang: string; identities?: MailIdentities | null; + flags: Record; onViewDoc: (id: number) => void; onOpenAccounting: () => void; onCompose: (init: ComposerInit, title?: string) => void; + onOpenDoc: (docType: DocType, senderEmail: string) => void; t: (k: string, d?: string) => string; -}> = ({ selection, account, lang, identities, onViewDoc, onOpenAccounting, onCompose, t }) => { +}> = ({ selection, account, identities, flags, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, t }) => { const detailQuery = useQuery({ queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null], queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id), @@ -419,23 +434,15 @@ const ReadingPane: React.FC<{ } : undefined; - // Create-from-template opens the composer with the rendered template loaded - // (freely editable); empty key = blank compose (gallery has no send template). - const onCreate = !isAcct - ? async (key: string, label: string) => { - if (!key) { onCompose({ to: recipient, subject: '', html: '' }, label); return; } - try { - const p = await emailService.previewTemplate(key, { customer_name: recipient.split('@')[0] || '' }, lang || 'en'); - onCompose({ to: recipient, subject: p.subject, html: p.body_html }, label); - } catch { - onCompose({ to: recipient, subject: label, html: '' }, label); - } - } + // Quote/Contract/Invoice/Gallery open the document-action flow (resolve the + // customer, then create-new or select-existing). Customer-facing streams only. + const onDoc = !isAcct && recipient + ? (docType: DocType) => onOpenDoc(docType, recipient) : undefined; return (
- +
{selection.kind === 'queue' ? ( detailQuery.isLoading ? : detailQuery.data ? ( @@ -570,10 +577,11 @@ const ReceivedDetail: React.FC<{ // ─────────────────────────────────────────────────────────────── toolbar ── const Toolbar: React.FC<{ isAcct: boolean; + flags: Record; onReply?: () => void; - onCreate?: (key: string, label: string) => void; + onDoc?: (docType: DocType) => void; t: (k: string, d?: string) => string; -}> = ({ isAcct, onReply, onCreate, t }) => { +}> = ({ isAcct, flags, onReply, onDoc, t }) => { const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => { const enabled = !!onClick; return ( @@ -589,8 +597,7 @@ const Toolbar: React.FC<{ ); }; - const create = (key: string, labelKey: string, fallback: string) => - onCreate ? () => onCreate(key, t(labelKey, fallback)) : undefined; + const doc = (docType: DocType) => (onDoc ? () => onDoc(docType) : undefined); return (
@@ -604,10 +611,10 @@ const Toolbar: React.FC<{ ) : ( <> - - - - + {flags.quotes && } + {flags.contracts && } + + {flags.bills && } )}