mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Also preserve item description line breaks and use local ported invoice links in DEV. Co-authored-by: Cursor <cursoragent@cursor.com>
607 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|