Files
dashboards/apps/business/src/pages/InvoicesPage.tsx
T
Alireza HassaniandCursor 2be3b3376d Polish public invoice layout: account grid, duration, and copy-to-clipboard.
Also preserve item description line breaks and use local ported invoice links in DEV.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:03:33 +03:30

607 lines
24 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { Copy, Eye, Pencil, Plus, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Pagination } from '../components/Pagination'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { PageTitle } from '../components/PageTitle'
import { isEmptyRichText } from '../components/RichTextEditor'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
import { getInvoicePublicUrlFallback } from '../lib/config'
import { getActiveBusinessDomain } from '../lib/businessContext'
import { formatCellForDisplay } from '../lib/cellNumber'
import { deleteInvoice, listInvoices, updateInvoiceStatus } from '../services/invoiceService'
import type { BusinessCustomer } from '../services/customerService'
import type { Invoice, InvoiceStatus } from '../types/invoice'
import { formatIrtPrice } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import styles from './InvoicesPage.module.css'
const PAGE_SIZE = 20
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'approved', 'paid', 'cancelled']
function formatDate(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' })
}
function statusLabelKey(status: InvoiceStatus): BusinessMessageKey {
return `invoices.status.${status}` as BusinessMessageKey
}
function statusClass(status: InvoiceStatus) {
return styles[`status_${status}` as keyof typeof styles] ?? styles.status_draft
}
function canEditInvoice(status: InvoiceStatus) {
return status !== 'approved'
}
function allowedStatusOptions(current: InvoiceStatus): InvoiceStatus[] {
if (current !== 'approved') return STATUS_OPTIONS
return STATUS_OPTIONS.filter(
(option) => option === 'approved' || option === 'paid' || option === 'cancelled',
)
}
function customerName(invoice: Invoice): string {
const name = [invoice.user?.firstName, invoice.user?.lastName].filter(Boolean).join(' ').trim()
return name || ''
}
export function InvoicesPage() {
const navigate = useNavigate()
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast()
const filterUserId = searchParams.get('userId') || ''
const presetCustomer = (location.state as { presetCustomer?: BusinessCustomer } | null)
?.presetCustomer
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
const [statusTarget, setStatusTarget] = useState<Invoice | null>(null)
const [statusUpdating, setStatusUpdating] = useState<InvoiceStatus | null>(null)
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
async function reload(signal?: AbortSignal) {
setLoading(true)
setError('')
try {
const list = await listInvoices(
{ page, pageSize: PAGE_SIZE, userId: filterUserId || undefined },
signal,
)
setInvoices(list.items)
setTotalPages(list.totalPages)
setTotal(list.total)
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('invoices.loadError'))
} finally {
setLoading(false)
}
}
useEffect(() => {
const controller = new AbortController()
void reload(controller.signal)
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, filterUserId])
useEffect(() => {
setPage(1)
}, [filterUserId])
const filterCustomerMatch = useMemo(() => {
if (!filterUserId) return undefined
return invoices.find((inv) => inv.userId === filterUserId)
}, [invoices, filterUserId])
const filterCustomerName = useMemo(() => {
if (!filterUserId) return ''
if (presetCustomer && presetCustomer.id === filterUserId) {
const fromPreset = [presetCustomer.firstName, presetCustomer.lastName]
.filter(Boolean)
.join(' ')
.trim()
if (fromPreset) return fromPreset
if (presetCustomer.label?.trim()) return presetCustomer.label.trim()
if (presetCustomer.cellNumber) return formatCellForDisplay(presetCustomer.cellNumber)
}
if (filterCustomerMatch) {
const fromInvoice = customerName(filterCustomerMatch)
if (fromInvoice) return fromInvoice
}
return ''
}, [filterUserId, presetCustomer, filterCustomerMatch])
const issueState =
presetCustomer && presetCustomer.id === filterUserId
? { presetCustomer }
: filterCustomerMatch?.user
? {
presetCustomer: {
id: filterCustomerMatch.user.id,
cellNumber: filterCustomerMatch.user.cell,
firstName: filterCustomerMatch.user.firstName,
lastName: filterCustomerMatch.user.lastName,
email: null,
label: filterCustomerName || filterCustomerMatch.user.cell,
},
}
: undefined
function clearFilter() {
const next = new URLSearchParams(searchParams)
next.delete('userId')
setSearchParams(next, { replace: true, state: null })
}
function openStatusModal(invoice: Invoice) {
setStatusTarget(invoice)
}
async function handleStatusSelect(next: InvoiceStatus) {
if (!statusTarget || statusUpdating) return
if (next === statusTarget.status) {
setStatusTarget(null)
return
}
setStatusUpdating(next)
try {
const updated = await updateInvoiceStatus(statusTarget.id, { status: next })
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
setDetailInvoice((current) => (current?.id === updated.id ? updated : current))
showToast(t('invoices.statusUpdated'), 'success')
setStatusTarget(null)
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoices.statusUpdateError'), 'error')
} finally {
setStatusUpdating(null)
}
}
async function handleRemove() {
if (!removeTarget) return
try {
await deleteInvoice(removeTarget.id)
showToast(t('invoices.removed'), 'success')
setRemoveTarget(null)
if (detailInvoice?.id === removeTarget.id) setDetailInvoice(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoices.removeError'), 'error')
}
}
function invoicePublicUrl(invoice: Invoice) {
// Local: public viewer is on super-admin Vite (port 5174), not the apex domain.
if (import.meta.env.DEV || import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL) {
return getInvoicePublicUrlFallback(invoice.publicId, getActiveBusinessDomain())
}
return (
invoice.publicUrl ||
getInvoicePublicUrlFallback(invoice.publicId, getActiveBusinessDomain())
)
}
async function copyPublicLink(invoice: Invoice) {
const url = invoicePublicUrl(invoice)
try {
await navigator.clipboard.writeText(url)
showToast(t('invoices.linkCopied'), 'success')
} catch {
showToast(t('invoices.linkCopyError'), 'error')
}
}
const issueHref = filterUserId ? `/invoices/new?userId=${filterUserId}` : '/invoices/new'
const countLabel =
total === 1 ? t('invoices.countOne', { count: total }) : t('invoices.countMany', { count: total })
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('nav.finance'), href: '/finance' },
{ label: t('title.invoices') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="INVOICES">{t('title.invoices')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('invoices.subtitle')}</p>
</div>
<Link to="/invoices/templates" className={`${formStyles.btn} ${formStyles.btnGhost}`}>
{t('invoices.templatesLink')}
</Link>
</div>
{filterUserId ? (
<div className={styles.filterBanner}>
<span>
{filterCustomerName
? t('invoices.filterBannerNamed', { name: filterCustomerName })
: t('invoices.filterBannerGeneric')}
</span>
<button type="button" className={styles.filterBannerCancel} onClick={clearFilter}>
{t('invoices.filterCancel')}
</button>
</div>
) : null}
{error ? <p className={pageStyles.pageSubtitleContent}>{error}</p> : null}
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.meta}>{loading ? t('invoices.loading') : countLabel}</div>
</div>
<table className={styles.table}>
<colgroup>
<col className={styles.colName} />
<col className={styles.colCustomer} />
<col className={styles.colIssued} />
<col className={styles.colStatus} />
<col className={styles.colTotal} />
<col className={styles.colLink} />
<col className={styles.colActions} />
</colgroup>
<thead>
<tr>
<th className={styles.th}>{t('invoices.col.name')}</th>
<th className={styles.th}>{t('invoices.col.customer')}</th>
<th className={styles.th}>{t('invoices.col.issued')}</th>
<th className={styles.th}>{t('invoices.col.status')}</th>
<th className={styles.th}>{t('invoices.col.total')}</th>
<th className={styles.th}>{t('invoices.col.link')}</th>
<th className={`${styles.th} ${styles.thActions}`}>{t('invoices.col.actions')}</th>
</tr>
</thead>
<tbody>
{!loading && invoices.length === 0 ? (
<tr>
<td className={styles.td} colSpan={7}>
{t('invoices.empty')}
</td>
</tr>
) : null}
{invoices.map((invoice) => {
const itemCount = invoice.items?.length ?? 0
const name = customerName(invoice)
return (
<tr key={invoice.id}>
<td className={styles.td}>
<div className={styles.itemTitle}>{invoice.name || `#${invoice.id}`}</div>
<div className={styles.subText}>
{itemCount === 1
? t('invoices.itemCountOne', { count: itemCount })
: t('invoices.itemCountMany', { count: itemCount })}
</div>
</td>
<td className={`${styles.td} ${styles.tdCustomer}`}>
<div className={styles.customerStack}>
<div className={styles.customerCell}>{name || '—'}</div>
{invoice.user?.cell ? (
<div className={styles.customerPhone} dir="ltr">
{formatCellForDisplay(invoice.user.cell)}
</div>
) : null}
</div>
</td>
<td className={styles.td}>{formatDate(invoice.issuedAt, locale)}</td>
<td className={styles.td}>
<button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(invoice.status)}`}
onClick={() => openStatusModal(invoice)}
title={t('invoices.changeStatus')}
aria-label={t('invoices.changeStatusAria', {
status: t(statusLabelKey(invoice.status)),
})}
>
{t(statusLabelKey(invoice.status))}
</button>
</td>
<td className={styles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
<td className={styles.td}>
<div className={styles.linkRow}>
<a
className={styles.publicLink}
href={invoicePublicUrl(invoice)}
target="_blank"
rel="noreferrer"
title={invoicePublicUrl(invoice)}
>
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
</a>
<Tooltip label={t('invoices.copyLink')}>
<button
type="button"
className={styles.copyLinkBtn}
onClick={() => void copyPublicLink(invoice)}
aria-label={t('invoices.copyLinkAria')}
>
<Copy size={14} />
</button>
</Tooltip>
</div>
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<Tooltip label={t('invoices.view')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => setDetailInvoice(invoice)}
aria-label={t('invoices.viewAria')}
>
<Eye size={16} />
</button>
</Tooltip>
<Tooltip
label={
canEditInvoice(invoice.status)
? t('invoices.edit')
: t('invoices.editDisabledTitle')
}
>
<span>
<button
type="button"
className={styles.actionBtn}
disabled={!canEditInvoice(invoice.status)}
onClick={() => navigate(`/invoices/${invoice.id}/edit`)}
aria-label={
canEditInvoice(invoice.status)
? t('invoices.editAria')
: t('invoices.editDisabledAria')
}
>
<Pencil size={16} />
</button>
</span>
</Tooltip>
<Tooltip label={t('invoices.remove')}>
<button
type="button"
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={() => setRemoveTarget(invoice)}
aria-label={t('invoices.removeAria')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{totalPages > 1 ? (
<div className={styles.pagination}>
<div>{countLabel}</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
) : null}
</div>
</div>
<Modal
open={!!detailInvoice}
title={detailInvoice ? detailInvoice.name || t('invoices.detailFallbackTitle') : ''}
onClose={() => setDetailInvoice(null)}
xl
>
{detailInvoice ? (
<>
<div className={styles.detailMeta}>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.billedTo')}</span>
<strong>{customerName(detailInvoice) || '—'}</strong>
{detailInvoice.user?.cell ? (
<div className={styles.subText} dir="ltr">
{formatCellForDisplay(detailInvoice.user.cell)}
</div>
) : null}
</div>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.status')}</span>
<button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(detailInvoice.status)}`}
onClick={() => openStatusModal(detailInvoice)}
title={t('invoices.changeStatus')}
aria-label={t('invoices.changeStatusAria', {
status: t(statusLabelKey(detailInvoice.status)),
})}
>
{t(statusLabelKey(detailInvoice.status))}
</button>
</div>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.total')}</span>
<strong>{formatIrtPrice(detailInvoice.total ?? 0)}</strong>
</div>
</div>
<div className={styles.detailMeta} style={{ gridTemplateColumns: '1fr' }}>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.link')}</span>
<div className={styles.linkRow}>
<a
className={styles.publicLink}
href={invoicePublicUrl(detailInvoice)}
target="_blank"
rel="noreferrer"
>
{invoicePublicUrl(detailInvoice).replace(/^https?:\/\//, '')}
</a>
<Tooltip label={t('invoices.copyLink')}>
<button
type="button"
className={styles.copyLinkBtn}
onClick={() => void copyPublicLink(detailInvoice)}
aria-label={t('invoices.copyLinkAria')}
>
<Copy size={14} />
</button>
</Tooltip>
</div>
</div>
</div>
{detailInvoice.topText && !isEmptyRichText(detailInvoice.topText) ? (
<div
className={styles.detailNotes}
dangerouslySetInnerHTML={{ __html: detailInvoice.topText }}
/>
) : null}
{detailInvoice.notes ? (
<p className={styles.detailNotes}>
{t('invoices.detail.notes', { notes: detailInvoice.notes })}
</p>
) : null}
<div className={styles.detailItems}>
{(detailInvoice.items ?? []).map((item) => (
<div key={item.id} className={styles.detailItem}>
<div className={styles.detailItemTop}>
<strong>{item.title}</strong>
<span>
{item.discountedPrice !== null && item.discountedPrice < item.price ? (
<>
<span className={styles.strike}>{formatIrtPrice(item.price)}</span>{' '}
{formatIrtPrice(item.discountedPrice)}
</>
) : (
formatIrtPrice(item.price)
)}
</span>
</div>
<div className={styles.detailItemMeta}>
{item.duration ? <span>{item.duration}</span> : null}
{item.worktime ? <span>{item.worktime}</span> : null}
</div>
{item.description ? (
<p className={styles.detailItemDesc}>{item.description}</p>
) : null}
</div>
))}
</div>
{(detailInvoice.keyPoints?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.keyPoints')}</h4>
<ul className={styles.keyPointList}>
{detailInvoice.keyPoints!.map((kp) => (
<li key={kp.id}>{kp.text}</li>
))}
</ul>
</div>
) : null}
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
<div className={styles.detailItems}>
{detailInvoice.accounts!.map((acc) => (
<div key={acc.id} className={styles.detailItem}>
<strong>{acc.bankName}</strong>
<div className={styles.detailItemMeta}>
{acc.accountHolderName ? <span>{acc.accountHolderName}</span> : null}
{acc.cardNumber ? <span dir="ltr">{acc.cardNumber}</span> : null}
{acc.iban ? <span dir="ltr">{acc.iban}</span> : null}
</div>
</div>
))}
</div>
</div>
) : null}
</>
) : null}
</Modal>
<Modal
open={!!statusTarget}
title={t('invoices.changeStatus')}
onClose={() => {
if (!statusUpdating) setStatusTarget(null)
}}
>
{statusTarget ? (
<div className={styles.statusOptionList} role="listbox" aria-label={t('invoices.col.status')}>
{allowedStatusOptions(statusTarget.status).map((option) => {
const selected = statusTarget.status === option
const saving = statusUpdating === option
return (
<button
key={option}
type="button"
role="option"
aria-selected={selected}
className={`${styles.statusChip} ${styles.statusChipBtn} ${styles.statusOption} ${statusClass(option)} ${selected ? styles.statusOptionSelected : ''}`}
disabled={Boolean(statusUpdating)}
onClick={() => void handleStatusSelect(option)}
>
{saving ? t('issueInvoice.saving') : t(statusLabelKey(option))}
</button>
)
})}
</div>
) : null}
</Modal>
<ConfirmDeleteModal
open={!!removeTarget}
title={t('invoices.deleteTitle')}
message={t('invoices.deleteMessage')}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void handleRemove()}
/>
<Tooltip label={t('invoices.issue')}>
<button
type="button"
className={styles.fab}
onClick={() => navigate(issueHref, { state: issueState })}
aria-label={t('invoices.issue')}
>
<Plus size={24} />
</button>
</Tooltip>
</main>
)
}