Files
dashboards/apps/business/src/pages/IssueInvoicePage.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

414 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { CustomerSearchSelect } from '../components/CustomerSearchSelect'
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 {
createInvoice,
getInvoice,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateInvoice,
} from '../services/invoiceService'
import { listCustomers, type BusinessCustomer } from '../services/customerService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildLineItemsPayload,
draftsFromInvoice,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
type DraftKeyPoint,
type DraftLineItem,
} from '../utils/invoiceDraft'
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import styles from './InvoicesPage.module.css'
function effectivePrice(price: number, discountedPrice: number | null | undefined) {
if (discountedPrice !== null && discountedPrice !== undefined && discountedPrice < price) {
return discountedPrice
}
return price
}
function customerFromInvoiceUser(user: {
id: string
firstName: string | null
lastName: string | null
cell: string
}): BusinessCustomer {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return {
id: user.id,
cellNumber: user.cell,
firstName: user.firstName,
lastName: user.lastName,
email: null,
label: name || user.cell,
}
}
export function IssueInvoicePage() {
const { invoiceId = '' } = useParams()
const isEdit = Boolean(invoiceId)
const navigate = useNavigate()
const location = useLocation()
const [searchParams] = useSearchParams()
const { showToast } = useToast()
const t = useT()
const urlUserId = searchParams.get('userId') || ''
const presetCustomer = (location.state as { presetCustomer?: BusinessCustomer } | null)
?.presetCustomer
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [locked, setLocked] = useState(false)
const [billedCustomer, setBilledCustomer] = useState<BusinessCustomer | null>(
presetCustomer && presetCustomer.id === urlUserId ? presetCustomer : null,
)
const [billedUserId, setBilledUserId] = useState(urlUserId)
const customerLocked = isEdit || Boolean(urlUserId)
const [sourceTemplateId, setSourceTemplateId] = useState('')
const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>()
const [invoiceName, setInvoiceName] = useState('')
const [topText, setTopText] = useState('')
const [notes, setNotes] = useState('')
const [draftItems, setDraftItems] = 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('')
const listPath = '/invoices'
const createTotal = useMemo(() => {
return draftItems.reduce((sum, item) => {
const price = parseIrtInput(item.price) ?? 0
const discounted = item.discountedPrice.trim() ? parseIrtInput(item.discountedPrice) : null
return sum + effectivePrice(price, discounted)
}, 0)
}, [draftItems])
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
setLocked(false)
try {
const [items, templates] = await Promise.all([
listInvoiceItemTemplates(controller.signal),
listInvoiceTemplates(controller.signal),
])
setItemTemplates(items.items.filter((i) => i.isActive))
setInvoiceTemplates(templates.items.filter((i) => i.isActive))
if (invoiceId) {
const invoice = await getInvoice(invoiceId, controller.signal)
if (invoice.status === 'approved') {
setLocked(true)
setError(t('issueInvoice.approvedLocked'))
}
if (invoice.user) {
setBilledCustomer(customerFromInvoiceUser(invoice.user))
}
setBilledUserId(invoice.userId)
const drafts = draftsFromInvoice(invoice)
setSourceTemplateId('')
setInvoiceTemplateId(invoice.invoiceTemplateId ?? undefined)
setInvoiceName(drafts.name)
setTopText(drafts.topText)
setNotes(drafts.notes)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
} else if (urlUserId && !presetCustomer) {
const listed = await listCustomers({ page: 1, pageSize: 100 }, controller.signal)
const match = listed.items.find((c) => c.id === urlUserId)
if (match) {
setBilledCustomer({
id: match.id,
cellNumber: match.cellNumber,
firstName: match.firstName,
lastName: match.lastName,
email: match.email,
label: match.label,
})
setBilledUserId(match.id)
}
}
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('issueInvoice.loadError'))
} finally {
setLoading(false)
}
}
void load()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoiceId])
function resetBlankDraft() {
setSourceTemplateId('')
setInvoiceTemplateId(undefined)
setInvoiceName('')
setTopText('')
setNotes('')
setDraftItems([emptyDraftItem()])
setKeyPoints([])
setAccounts([])
setSelectedItemTemplateId('')
setFormError('')
}
function applyInvoiceTemplate(templateId: string) {
if (locked) return
setSourceTemplateId(templateId)
if (!templateId) {
resetBlankDraft()
return
}
const template = invoiceTemplates.find((tpl) => tpl.id === templateId)
if (!template) return
const drafts = draftsFromInvoiceTemplate(template)
setInvoiceTemplateId(template.id)
setInvoiceName(drafts.name)
setTopText(drafts.topText)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
setFormError('')
}
function handleCustomerChange(customer: BusinessCustomer | null) {
setBilledCustomer(customer)
setBilledUserId(customer?.id ?? '')
}
async function handleSubmit() {
if (locked) return
setFormError('')
if (!billedUserId) {
setFormError(t('issueInvoice.recipientRequired'))
return
}
let items
try {
items = buildLineItemsPayload(draftItems, t)
} catch (err) {
setFormError(err instanceof Error ? err.message : t('issueInvoice.loadError'))
return
}
setSubmitting(true)
try {
if (isEdit) {
await updateInvoice(invoiceId, {
userId: billedUserId,
items,
name: invoiceName.trim() || null,
topText: isEmptyRichText(topText) ? null : topText,
notes: notes.trim() || null,
invoiceTemplateId: invoiceTemplateId ?? null,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
})
showToast(t('issueInvoice.updated'), 'success')
} else {
await createInvoice({
userId: billedUserId,
items,
name: invoiceName.trim() || undefined,
topText: isEmptyRichText(topText) ? undefined : topText,
notes: notes.trim() || undefined,
invoiceTemplateId,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
})
showToast(t('issueInvoice.issued'), 'success')
}
navigate(listPath)
} catch (err) {
setFormError(
err instanceof ApiError
? err.message
: isEdit
? t('issueInvoice.updateError')
: t('issueInvoice.createError'),
)
} finally {
setSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<Link to={listPath} className={formStyles.backLink}>
<ArrowLeft size={16} />
{t('issueInvoice.backToInvoices')}
</Link>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('title.editInvoice') : t('title.issueInvoice')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit ? t('issueInvoice.subtitleEdit') : t('issueInvoice.subtitleNew')}
</p>
</div>
</div>
{error ? <p className={formStyles.alertError}>{error}</p> : null}
{loading ? <p className={styles.meta}>{t('issueInvoice.loading')}</p> : null}
{!loading && !locked ? (
<section className={formStyles.section}>
<div className={`${formStyles.metaGrid}${isEdit ? ` ${formStyles.metaGridTwo}` : ''}`}>
<div className={formStyles.field}>
<label htmlFor="invoice-recipient">{t('issueInvoice.recipient')}</label>
<CustomerSearchSelect
id="invoice-recipient"
value={billedCustomer}
onChange={handleCustomerChange}
disabled={customerLocked}
/>
</div>
{!isEdit ? (
<div className={formStyles.field}>
<label htmlFor="source-template">{t('issueInvoice.startFromTemplate')}</label>
<select
id="source-template"
value={sourceTemplateId}
onChange={(e) => applyInvoiceTemplate(e.target.value)}
>
<option value="">{t('issueInvoice.blankInvoice')}</option>
{invoiceTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : null}
<div className={formStyles.field}>
<label htmlFor="invoice-name">{t('issueInvoice.name')}</label>
<input
id="invoice-name"
value={invoiceName}
onChange={(e) => setInvoiceName(e.target.value)}
placeholder={t('issueInvoice.namePlaceholder')}
/>
</div>
</div>
{!isEdit && invoiceTemplates.length === 0 ? (
<p className={styles.subText}>
{t('issueInvoice.noTemplatesHint').split('{link}')[0]}
<Link to="/invoices/templates">{t('issueInvoice.noTemplatesLink')}</Link>
{t('issueInvoice.noTemplatesHint').split('{link}')[1]}
</p>
) : null}
<div className={`${formStyles.field} ${formStyles.fullField}`}>
<label>{t('issueInvoice.topText')}</label>
<RichTextEditor
value={topText}
onChange={setTopText}
placeholder={t('issueInvoice.topTextPlaceholder')}
editorMinHeight={88}
/>
</div>
<div className={`${formStyles.field} ${formStyles.fullField}`}>
<label htmlFor="invoice-notes">{t('issueInvoice.notes')}</label>
<input
id="invoice-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('issueInvoice.notesPlaceholder')}
/>
</div>
<InvoiceDraftFields
itemTemplates={itemTemplates}
items={draftItems}
keyPoints={keyPoints}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
onItemsChange={setDraftItems}
onKeyPointsChange={setKeyPoints}
onAccountsChange={setAccounts}
afterItems={
<div className={formStyles.totalRow}>
<span className={formStyles.totalLabel}>{t('issueInvoice.totalLabel')}</span>
<strong className={formStyles.totalValue}>{formatIrtPrice(createTotal)}</strong>
</div>
}
/>
{formError ? <p className={formStyles.alertError}>{formError}</p> : null}
<div className={formStyles.actionsRow}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => navigate(listPath)}
disabled={submitting}
>
{t('issueInvoice.cancel')}
</button>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting
? isEdit
? t('issueInvoice.saving')
: t('issueInvoice.issuing')
: isEdit
? t('issueInvoice.save')
: t('issueInvoice.issue')}
</button>
</div>
</section>
) : null}
{!loading && locked ? (
<div className={formStyles.actionsRow} style={{ justifyContent: 'flex-start' }}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => navigate(listPath)}
>
{t('issueInvoice.backToInvoices')}
</button>
</div>
) : null}
</main>
)
}