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>
This commit is contained in:
co-authored by
Cursor
parent
722933dda9
commit
917b840ee5
@@ -0,0 +1,202 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user