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,413 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user