Files
dashboards/apps/business/src/pages/InvoiceTemplateEditorPage.tsx
T
Alireza HassaniandCursor 917b840ee5 Add business Finance hub with invoices and templates.
Ship invoice issue/list/templates under Finance, fix tenant theme reset and owner role badge, and show customers-joined charts by Persian months.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:17:03 +03:30

203 lines
6.9 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { InvoiceDraftFields } from '../components/InvoiceDraftFields'
import { isEmptyRichText, RichTextEditor } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
createInvoiceTemplate,
getInvoiceTemplate,
listInvoiceItemTemplates,
updateInvoiceTemplate,
} from '../services/invoiceService'
import type { InvoiceItemTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildTemplateItemsPayload,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
type DraftKeyPoint,
type DraftLineItem,
} from '../utils/invoiceDraft'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import tableStyles from './InvoicesPage.module.css'
export function InvoiceTemplateEditorPage() {
const { templateId } = useParams()
const isEdit = Boolean(templateId)
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [loading, setLoading] = useState(isEdit)
const [error, setError] = useState('')
const [name, setName] = useState('')
const [topText, setTopText] = useState('')
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('')
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const itemsRes = await listInvoiceItemTemplates(controller.signal)
setItemTemplates(itemsRes.items.filter((i) => i.isActive))
if (templateId) {
const template = await getInvoiceTemplate(templateId, controller.signal)
const drafts = draftsFromInvoiceTemplate(template)
setName(drafts.name)
setTopText(drafts.topText)
setItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
}
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.loadError'))
} finally {
setLoading(false)
}
}
void load()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId])
async function handleSave() {
setFormError('')
if (!name.trim()) {
setFormError(t('invoiceTemplateEditor.nameRequired'))
return
}
let lineItems
try {
lineItems = buildTemplateItemsPayload(items, t)
} catch (err) {
setFormError(err instanceof Error ? err.message : t('invoiceTemplateEditor.saveError'))
return
}
const payload = {
name: name.trim(),
topText: isEmptyRichText(topText) ? undefined : topText,
items: lineItems,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
}
setSubmitting(true)
try {
if (templateId) {
await updateInvoiceTemplate(templateId, payload)
showToast(t('invoiceTemplateEditor.updated'), 'success')
} else {
await createInvoiceTemplate(payload)
showToast(t('invoiceTemplateEditor.created'), 'success')
}
navigate('/invoices/templates')
} catch (err) {
setFormError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.saveError'))
} finally {
setSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<Link to="/invoices/templates" className={formStyles.backLink}>
<ArrowLeft size={16} />
{t('invoiceTemplateEditor.backToTemplates')}
</Link>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('invoiceTemplateEditor.editTitle') : t('invoiceTemplateEditor.addTitle')}
</h2>
<p className={pageStyles.pageSubtitle}>{t('invoiceTemplateEditor.subtitle')}</p>
</div>
</div>
{error ? <p className={formStyles.alertError}>{error}</p> : null}
{loading ? <p className={tableStyles.meta}>{t('invoiceTemplateEditor.loading')}</p> : null}
{!loading ? (
<section className={formStyles.section}>
<div className={formStyles.metaGrid}>
<div className={`${formStyles.field} ${formStyles.span2}`}>
<label htmlFor="tpl-name">{t('invoiceTemplateEditor.name')}</label>
<input
id="tpl-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('invoiceTemplateEditor.namePlaceholder')}
/>
</div>
</div>
<div className={`${formStyles.field} ${formStyles.fullField}`}>
<label>{t('invoiceTemplateEditor.topText')}</label>
<RichTextEditor
value={topText}
onChange={setTopText}
placeholder={t('invoiceTemplateEditor.topTextPlaceholder')}
editorMinHeight={88}
/>
</div>
<InvoiceDraftFields
itemTemplates={itemTemplates}
items={items}
keyPoints={keyPoints}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
onItemsChange={setItems}
onKeyPointsChange={setKeyPoints}
onAccountsChange={setAccounts}
/>
{formError ? <p className={formStyles.alertError}>{formError}</p> : null}
<div className={formStyles.actionsRow}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => navigate('/invoices/templates')}
disabled={submitting}
>
{t('invoiceTemplateEditor.cancel')}
</button>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => void handleSave()}
disabled={submitting}
>
{submitting
? t('invoiceTemplateEditor.saving')
: isEdit
? t('invoiceTemplateEditor.save')
: t('invoiceTemplateEditor.create')}
</button>
</div>
</section>
) : null}
</main>
)
}