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>
203 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|