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>
414 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|