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.
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { ArrowLeft, Eye, Save } from 'lucide-react';
|
import { ArrowLeft, Eye, Save } from 'lucide-react';
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '../../../services/contracts.service';
|
} from '../../../services/contracts.service';
|
||||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||||
|
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||||
|
|
||||||
interface BlockRow {
|
interface BlockRow {
|
||||||
blockId: number;
|
blockId: number;
|
||||||
@@ -39,6 +40,7 @@ interface BlockRow {
|
|||||||
export const ContractEditorPage: React.FC = () => {
|
export const ContractEditorPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
const numericId = id ? parseInt(id, 10) : null;
|
const numericId = id ? parseInt(id, 10) : null;
|
||||||
@@ -67,6 +69,28 @@ export const ContractEditorPage: React.FC = () => {
|
|||||||
const [projectId, setProjectId] = useState<number | null>(null);
|
const [projectId, setProjectId] = useState<number | null>(null);
|
||||||
const [blocks, setBlocks] = useState<BlockRow[]>([]);
|
const [blocks, setBlocks] = useState<BlockRow[]>([]);
|
||||||
|
|
||||||
|
// 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.
|
// Load existing contract on edit.
|
||||||
const { data: existing, isLoading: existingLoading } = useQuery({
|
const { data: existing, isLoading: existingLoading } = useQuery({
|
||||||
queryKey: ['contract', numericId],
|
queryKey: ['contract', numericId],
|
||||||
|
|||||||
@@ -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<DocType, { label: string; newRoute: string; hasExisting: boolean }> = {
|
||||||
|
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<SelCustomer | null>(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<DocRow[]> => {
|
||||||
|
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 = `<p><br></p><p>${cfg.label} <strong>${d.number}</strong></p><p><br></p>`;
|
||||||
|
onCompose({ to: customer?.email || senderEmail, subject: `${cfg.label} ${d.number}`, html });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(560px,96vw)] max-h-[88vh] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
|
||||||
|
{t(`messages.doc.${docType}`, cfg.label)}
|
||||||
|
</span>
|
||||||
|
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<CustomerPicker
|
||||||
|
value={customer?.id ?? null}
|
||||||
|
label={t('messages.customer', 'Customer')}
|
||||||
|
onSelect={pick}
|
||||||
|
onCreate={pick}
|
||||||
|
onClear={() => setCustomer(null)}
|
||||||
|
/>
|
||||||
|
{resolving && <p className="mt-1 text-xs text-neutral-400">{t('messages.resolvingCustomer', 'Matching the sender to a customer…')}</p>}
|
||||||
|
{!resolving && !customer && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('messages.noCustomerMatch', 'No customer matched this sender — search for one or create a new customer above.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customer && (
|
||||||
|
<>
|
||||||
|
<Button variant="primary" onClick={createNew} leftIcon={<Plus className="w-4 h-4" />} className="w-full justify-center">
|
||||||
|
{t('messages.createNewDoc', 'Create new {{label}}', { label: t(`messages.doc.${docType}`, cfg.label) } as any)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{cfg.hasExisting && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
|
||||||
|
{t('messages.existingDocs', 'Or reference an existing one')}
|
||||||
|
</div>
|
||||||
|
{existing.isLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : (existing.data && existing.data.length > 0) ? (
|
||||||
|
<div className="flex flex-col gap-1.5 max-h-[38vh] overflow-y-auto">
|
||||||
|
{existing.data.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => pickExisting(d)}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 text-left"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 text-neutral-400 flex-none" />
|
||||||
|
<span className="font-mono text-[13px] text-neutral-800 dark:text-neutral-100">{d.number}</span>
|
||||||
|
<span className="ml-auto text-[11px] text-neutral-400">{d.status}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.noExistingDocs', 'No existing documents for this customer yet.')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!cfg.hasExisting && (
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('messages.galleryCreateOnly', 'Galleries are event-based — this opens the event editor, where you can assign the customer.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentActionModal;
|
||||||
@@ -12,6 +12,8 @@ import { emailService, type ReceivedEmail, type MailIdentities } from '../../../
|
|||||||
import { accountingService } from '../../../services/accounting.service';
|
import { accountingService } from '../../../services/accounting.service';
|
||||||
import { Loading } from '../../../components/common';
|
import { Loading } from '../../../components/common';
|
||||||
import { MessageComposer, type ComposerInit } from './MessageComposer';
|
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
|
* Admin "Messages" — read-only viewer over the mail picpeak already
|
||||||
@@ -63,12 +65,14 @@ const STATUS_STYLES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MessagesPage: React.FC = () => {
|
export const MessagesPage: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
const [pdfDocId, setPdfDocId] = useState<number | null>(null);
|
const [pdfDocId, setPdfDocId] = useState<number | null>(null);
|
||||||
const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(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.
|
// "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop.
|
||||||
const sync = useMutation({
|
const sync = useMutation({
|
||||||
@@ -265,11 +269,12 @@ export const MessagesPage: React.FC = () => {
|
|||||||
<ReadingPane
|
<ReadingPane
|
||||||
selection={selection}
|
selection={selection}
|
||||||
account={folder.a}
|
account={folder.a}
|
||||||
lang={i18n.language}
|
|
||||||
identities={identities}
|
identities={identities}
|
||||||
|
flags={flags}
|
||||||
onViewDoc={setPdfDocId}
|
onViewDoc={setPdfDocId}
|
||||||
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
||||||
onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })}
|
onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })}
|
||||||
|
onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -286,6 +291,15 @@ export const MessagesPage: React.FC = () => {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{docAction && (
|
||||||
|
<DocumentActionModal
|
||||||
|
docType={docAction.docType}
|
||||||
|
senderEmail={docAction.senderEmail}
|
||||||
|
onCompose={(init) => { setDocAction(null); setComposer({ init: { to: init.to, subject: init.subject, html: init.html }, title: init.subject, accountKey: 'customers' }); }}
|
||||||
|
onClose={() => setDocAction(null)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -375,13 +389,14 @@ const MessageList: React.FC<{
|
|||||||
const ReadingPane: React.FC<{
|
const ReadingPane: React.FC<{
|
||||||
selection: Selection;
|
selection: Selection;
|
||||||
account: Account;
|
account: Account;
|
||||||
lang: string;
|
|
||||||
identities?: MailIdentities | null;
|
identities?: MailIdentities | null;
|
||||||
|
flags: Record<string, boolean>;
|
||||||
onViewDoc: (id: number) => void;
|
onViewDoc: (id: number) => void;
|
||||||
onOpenAccounting: () => void;
|
onOpenAccounting: () => void;
|
||||||
onCompose: (init: ComposerInit, title?: string) => void;
|
onCompose: (init: ComposerInit, title?: string) => void;
|
||||||
|
onOpenDoc: (docType: DocType, senderEmail: string) => void;
|
||||||
t: (k: string, d?: string) => string;
|
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({
|
const detailQuery = useQuery({
|
||||||
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
||||||
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
||||||
@@ -419,23 +434,15 @@ const ReadingPane: React.FC<{
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Create-from-template opens the composer with the rendered template loaded
|
// Quote/Contract/Invoice/Gallery open the document-action flow (resolve the
|
||||||
// (freely editable); empty key = blank compose (gallery has no send template).
|
// customer, then create-new or select-existing). Customer-facing streams only.
|
||||||
const onCreate = !isAcct
|
const onDoc = !isAcct && recipient
|
||||||
? async (key: string, label: string) => {
|
? (docType: DocType) => onOpenDoc(docType, recipient)
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
<Toolbar isAcct={isAcct} onReply={onReply} onCreate={onCreate} t={t} />
|
<Toolbar isAcct={isAcct} flags={flags} onReply={onReply} onDoc={onDoc} t={t} />
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{selection.kind === 'queue' ? (
|
{selection.kind === 'queue' ? (
|
||||||
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
||||||
@@ -570,10 +577,11 @@ const ReceivedDetail: React.FC<{
|
|||||||
// ─────────────────────────────────────────────────────────────── toolbar ──
|
// ─────────────────────────────────────────────────────────────── toolbar ──
|
||||||
const Toolbar: React.FC<{
|
const Toolbar: React.FC<{
|
||||||
isAcct: boolean;
|
isAcct: boolean;
|
||||||
|
flags: Record<string, boolean>;
|
||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onCreate?: (key: string, label: string) => void;
|
onDoc?: (docType: DocType) => void;
|
||||||
t: (k: string, d?: string) => string;
|
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 Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
|
||||||
const enabled = !!onClick;
|
const enabled = !!onClick;
|
||||||
return (
|
return (
|
||||||
@@ -589,8 +597,7 @@ const Toolbar: React.FC<{
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const create = (key: string, labelKey: string, fallback: string) =>
|
const doc = (docType: DocType) => (onDoc ? () => onDoc(docType) : undefined);
|
||||||
onCreate ? () => onCreate(key, t(labelKey, fallback)) : undefined;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 flex-wrap px-3 py-2 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
<div className="flex items-center gap-1 flex-wrap px-3 py-2 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||||
<Tb icon={Reply} label={t('messages.reply', 'Reply')} onClick={onReply} />
|
<Tb icon={Reply} label={t('messages.reply', 'Reply')} onClick={onReply} />
|
||||||
@@ -604,10 +611,10 @@ const Toolbar: React.FC<{
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={create('quote_sent', 'messages.createQuote', 'Quote')} />
|
{flags.quotes && <Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={doc('quote')} />}
|
||||||
<Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent onClick={create('contract_sent', 'messages.createContract', 'Contract')} />
|
{flags.contracts && <Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent onClick={doc('contract')} />}
|
||||||
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={create('', 'messages.createGallery', 'Gallery')} />
|
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={doc('gallery')} />
|
||||||
<Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={create('invoice_sent', 'messages.createInvoice', 'Invoice')} />
|
{flags.bills && <Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={doc('invoice')} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
|
|||||||
Reference in New Issue
Block a user