mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Platform invoice templates live under Settings; each business can list and issue invoices with optional name and a meshkee.com public link. Co-authored-by: Cursor <cursoragent@cursor.com>
717 lines
25 KiB
TypeScript
717 lines
25 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import {
|
|
ArrowLeft,
|
|
Copy,
|
|
Eye,
|
|
FilePlus2,
|
|
Pencil,
|
|
Plus,
|
|
Trash2,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|
import { Modal } from '../components/Modal'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import { getPlatformInvoicePublicUrl } from '../lib/config'
|
|
import { getBusiness, type BusinessDetail } from '../services/businessService'
|
|
import {
|
|
createBusinessInvoice,
|
|
deleteBusinessInvoice,
|
|
listBusinessInvoices,
|
|
listInvoiceItemTemplates,
|
|
updateBusinessInvoiceStatus,
|
|
} from '../services/invoiceService'
|
|
import type {
|
|
Invoice,
|
|
InvoiceItemInput,
|
|
InvoiceItemTemplate,
|
|
InvoiceStatus,
|
|
} from '../types/invoice'
|
|
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import tableStyles from './BusinessesPage.module.css'
|
|
import styles from './BusinessInvoicesPage.module.css'
|
|
|
|
type DraftItem = {
|
|
key: string
|
|
templateId?: string
|
|
title: string
|
|
duration: string
|
|
worktime: string
|
|
description: string
|
|
price: string
|
|
discountedPrice: string
|
|
}
|
|
|
|
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'paid', 'cancelled']
|
|
|
|
function formatDate(value: string) {
|
|
const d = new Date(value)
|
|
if (Number.isNaN(d.getTime())) return value
|
|
return d.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit',
|
|
})
|
|
}
|
|
|
|
function emptyDraftItem(): DraftItem {
|
|
return {
|
|
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
title: '',
|
|
duration: '',
|
|
worktime: '',
|
|
description: '',
|
|
price: '',
|
|
discountedPrice: '',
|
|
}
|
|
}
|
|
|
|
function fromTemplate(template: InvoiceItemTemplate): DraftItem {
|
|
return {
|
|
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
templateId: template.id,
|
|
title: template.title,
|
|
duration: template.duration ?? '',
|
|
worktime: template.worktime ?? '',
|
|
description: template.description ?? '',
|
|
price: formatIrtInput(String(Math.round(template.price))),
|
|
discountedPrice:
|
|
template.discountedPrice === null || template.discountedPrice === undefined
|
|
? ''
|
|
: formatIrtInput(String(Math.round(template.discountedPrice))),
|
|
}
|
|
}
|
|
|
|
function statusLabel(status: InvoiceStatus) {
|
|
return status.charAt(0).toUpperCase() + status.slice(1)
|
|
}
|
|
|
|
function effectivePrice(price: number, discountedPrice: number | null | undefined) {
|
|
if (discountedPrice !== null && discountedPrice !== undefined && discountedPrice < price) {
|
|
return discountedPrice
|
|
}
|
|
return price
|
|
}
|
|
|
|
export function BusinessInvoicesPage() {
|
|
const { businessId = '' } = useParams()
|
|
const { showToast } = useToast()
|
|
|
|
const [business, setBusiness] = useState<BusinessDetail | null>(null)
|
|
const [invoices, setInvoices] = useState<Invoice[]>([])
|
|
const [templates, setTemplates] = useState<InvoiceItemTemplate[]>([])
|
|
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 [createOpen, setCreateOpen] = useState(false)
|
|
const [draftItems, setDraftItems] = useState<DraftItem[]>([emptyDraftItem()])
|
|
const [invoiceName, setInvoiceName] = useState('')
|
|
const [notes, setNotes] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [formError, setFormError] = useState('')
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
|
|
|
const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
|
|
const [statusUpdating, setStatusUpdating] = useState(false)
|
|
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
|
|
|
|
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])
|
|
|
|
async function reload(signal?: AbortSignal) {
|
|
if (!businessId) return
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const [biz, list, tpl] = await Promise.all([
|
|
getBusiness(businessId, signal),
|
|
listBusinessInvoices(businessId, { page, pageSize: 20 }, signal),
|
|
listInvoiceItemTemplates(signal),
|
|
])
|
|
setBusiness(biz)
|
|
setInvoices(list.items)
|
|
setTotalPages(list.totalPages)
|
|
setTotal(list.total)
|
|
setTemplates(tpl.items.filter((t) => t.isActive))
|
|
} catch (err) {
|
|
if (isAbortError(err)) return
|
|
setError(err instanceof ApiError ? err.message : 'Unable to load invoices.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
void reload(controller.signal)
|
|
return () => controller.abort()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [businessId, page])
|
|
|
|
function openCreate() {
|
|
setDraftItems([emptyDraftItem()])
|
|
setInvoiceName('')
|
|
setNotes('')
|
|
setSelectedTemplateId('')
|
|
setFormError('')
|
|
setCreateOpen(true)
|
|
}
|
|
|
|
function updateDraftItem(key: string, patch: Partial<DraftItem>) {
|
|
setDraftItems((items) => items.map((item) => (item.key === key ? { ...item, ...patch } : item)))
|
|
}
|
|
|
|
function removeDraftItem(key: string) {
|
|
setDraftItems((items) => (items.length <= 1 ? items : items.filter((item) => item.key !== key)))
|
|
}
|
|
|
|
function addCustomItem() {
|
|
setDraftItems((items) => [...items, emptyDraftItem()])
|
|
}
|
|
|
|
function addFromTemplate() {
|
|
const template = templates.find((t) => t.id === selectedTemplateId)
|
|
if (!template) return
|
|
setDraftItems((items) => {
|
|
const onlyEmpty =
|
|
items.length === 1 &&
|
|
!items[0].title.trim() &&
|
|
!items[0].price.trim() &&
|
|
!items[0].description.trim()
|
|
return onlyEmpty ? [fromTemplate(template)] : [...items, fromTemplate(template)]
|
|
})
|
|
setSelectedTemplateId('')
|
|
}
|
|
|
|
function buildItemsPayload(): InvoiceItemInput[] {
|
|
return draftItems.map((item) => {
|
|
const title = item.title.trim()
|
|
const price = parseIrtInput(item.price)
|
|
if (!title) throw new Error('Each item needs a title.')
|
|
if (price === null) throw new Error(`Price is required for “${title || 'item'}”.`)
|
|
const discountedPrice = item.discountedPrice.trim()
|
|
? parseIrtInput(item.discountedPrice)
|
|
: null
|
|
if (item.discountedPrice.trim() && discountedPrice === null) {
|
|
throw new Error(`Discounted price is invalid for “${title}”.`)
|
|
}
|
|
if (discountedPrice !== null && discountedPrice > price) {
|
|
throw new Error(`Discounted price cannot exceed price for “${title}”.`)
|
|
}
|
|
return {
|
|
templateId: item.templateId,
|
|
title,
|
|
duration: item.duration.trim() || undefined,
|
|
worktime: item.worktime.trim() || undefined,
|
|
description: item.description.trim() || undefined,
|
|
price,
|
|
discountedPrice,
|
|
}
|
|
})
|
|
}
|
|
|
|
async function handleCreate() {
|
|
setFormError('')
|
|
let items: InvoiceItemInput[]
|
|
try {
|
|
items = buildItemsPayload()
|
|
} catch (err) {
|
|
setFormError(err instanceof Error ? err.message : 'Invalid invoice items.')
|
|
return
|
|
}
|
|
|
|
setSubmitting(true)
|
|
try {
|
|
await createBusinessInvoice(businessId, {
|
|
items,
|
|
name: invoiceName.trim() || undefined,
|
|
notes: notes.trim() || undefined,
|
|
})
|
|
showToast('Invoice issued.', 'success')
|
|
setCreateOpen(false)
|
|
setPage(1)
|
|
await reload()
|
|
} catch (err) {
|
|
setFormError(err instanceof ApiError ? err.message : 'Unable to create invoice.')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleStatusChange(next: InvoiceStatus) {
|
|
if (!detailInvoice) return
|
|
setStatusUpdating(true)
|
|
try {
|
|
const updated = await updateBusinessInvoiceStatus(businessId, detailInvoice.id, {
|
|
status: next,
|
|
})
|
|
setDetailInvoice(updated)
|
|
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
|
|
showToast('Invoice status updated.', 'success')
|
|
} catch (err) {
|
|
showToast(err instanceof ApiError ? err.message : 'Unable to update status.', 'error')
|
|
} finally {
|
|
setStatusUpdating(false)
|
|
}
|
|
}
|
|
|
|
async function handleRemove() {
|
|
if (!removeTarget) return
|
|
try {
|
|
await deleteBusinessInvoice(businessId, removeTarget.id)
|
|
showToast('Invoice removed.', 'success')
|
|
setRemoveTarget(null)
|
|
if (detailInvoice?.id === removeTarget.id) setDetailInvoice(null)
|
|
await reload()
|
|
} catch (err) {
|
|
showToast(err instanceof ApiError ? err.message : 'Unable to remove invoice.', 'error')
|
|
}
|
|
}
|
|
|
|
async function copyPublicLink(invoice: Invoice) {
|
|
const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
|
try {
|
|
await navigator.clipboard.writeText(url)
|
|
showToast('Invoice link copied.', 'success')
|
|
} catch {
|
|
showToast('Unable to copy link.', 'error')
|
|
}
|
|
}
|
|
|
|
function invoicePublicUrl(invoice: Invoice) {
|
|
return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
|
}
|
|
|
|
const businessName = business?.nameFa || business?.name || 'Business'
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<Link to="/businesses" className={styles.backLink}>
|
|
<ArrowLeft size={16} />
|
|
Back to businesses
|
|
</Link>
|
|
<h2 className={pageStyles.pageTitle}>Invoices · {businessName}</h2>
|
|
<p className={pageStyles.pageSubtitle}>
|
|
List invoices issued to this business, or create a new one from predefined or custom
|
|
items.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={openCreate}
|
|
>
|
|
<FilePlus2 size={16} />
|
|
Issue invoice
|
|
</button>
|
|
</div>
|
|
|
|
{error ? <p className={styles.alertError}>{error}</p> : null}
|
|
|
|
<div className={tableStyles.tableWrap}>
|
|
<div className={tableStyles.tableHeader}>
|
|
<div className={tableStyles.tableHeaderTitle}>Invoices</div>
|
|
<div className={tableStyles.meta}>
|
|
{loading ? 'Loading…' : `${total} invoice${total === 1 ? '' : 's'}`}
|
|
</div>
|
|
</div>
|
|
<table className={tableStyles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th className={tableStyles.th}>Name</th>
|
|
<th className={tableStyles.th}>Issued</th>
|
|
<th className={tableStyles.th}>Status</th>
|
|
<th className={tableStyles.th}>Total</th>
|
|
<th className={tableStyles.th}>Link</th>
|
|
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{!loading && invoices.length === 0 ? (
|
|
<tr>
|
|
<td className={tableStyles.td} colSpan={6}>
|
|
No invoices yet for this business.
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
{invoices.map((invoice) => (
|
|
<tr key={invoice.id}>
|
|
<td className={tableStyles.td}>
|
|
<div className={styles.itemTitle}>{invoice.name || `Invoice #${invoice.id}`}</div>
|
|
<div className={tableStyles.subText}>
|
|
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
|
|
</div>
|
|
</td>
|
|
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
|
|
<td className={tableStyles.td}>
|
|
<span className={`${styles.statusChip} ${styles[`status_${invoice.status}`]}`}>
|
|
{statusLabel(invoice.status)}
|
|
</span>
|
|
</td>
|
|
<td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
|
|
<td className={tableStyles.td}>
|
|
<div className={styles.linkRow}>
|
|
<a
|
|
className={styles.publicLink}
|
|
href={invoicePublicUrl(invoice)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={invoicePublicUrl(invoice)}
|
|
>
|
|
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
className={styles.copyLinkBtn}
|
|
onClick={() => void copyPublicLink(invoice)}
|
|
title="Copy link"
|
|
aria-label="Copy invoice link"
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
|
<div className={tableStyles.rowActions}>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.controlBtn}
|
|
onClick={() => setDetailInvoice(invoice)}
|
|
title="View"
|
|
aria-label="View invoice"
|
|
>
|
|
<Eye size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
|
onClick={() => setRemoveTarget(invoice)}
|
|
title="Remove"
|
|
aria-label="Remove invoice"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 ? (
|
|
<div className={tableStyles.pagination}>
|
|
<div>
|
|
Page {page} / {totalPages}
|
|
</div>
|
|
<div className={tableStyles.pagerBtns}>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.pageBtn}
|
|
disabled={page <= 1 || loading}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
Prev
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.pageBtn}
|
|
disabled={page >= totalPages || loading}
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<Modal
|
|
open={createOpen}
|
|
title={`Issue invoice · ${businessName}`}
|
|
onClose={() => !submitting && setCreateOpen(false)}
|
|
xl
|
|
>
|
|
<div className={styles.metaGrid}>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="invoice-name">Name (optional)</label>
|
|
<input
|
|
id="invoice-name"
|
|
value={invoiceName}
|
|
onChange={(e) => setInvoiceName(e.target.value)}
|
|
placeholder="e.g. Website redesign package"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="invoice-notes">Notes</label>
|
|
<input
|
|
id="invoice-notes"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="Optional notes for this invoice"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.templateBar}>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="template-pick">Add from predefined</label>
|
|
<select
|
|
id="template-pick"
|
|
value={selectedTemplateId}
|
|
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
|
>
|
|
<option value="">Select an item…</option>
|
|
{templates.map((template) => (
|
|
<option key={template.id} value={template.id}>
|
|
{template.title} · {formatIrtPrice(template.price)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={addFromTemplate}
|
|
disabled={!selectedTemplateId}
|
|
>
|
|
<Plus size={16} />
|
|
Add item
|
|
</button>
|
|
</div>
|
|
|
|
{templates.length === 0 ? (
|
|
<p className={styles.templateHint}>
|
|
No predefined items yet. Manage them in{' '}
|
|
<Link to="/settings">Settings → Invoices</Link>, or add custom lines below.
|
|
</p>
|
|
) : null}
|
|
|
|
<div className={styles.itemsStack}>
|
|
{draftItems.map((item, index) => (
|
|
<div key={item.key} className={styles.itemCard}>
|
|
<div className={styles.itemCardHeader}>
|
|
<span>Item {index + 1}</span>
|
|
<button
|
|
type="button"
|
|
className={styles.removeItemBtn}
|
|
onClick={() => removeDraftItem(item.key)}
|
|
disabled={draftItems.length <= 1}
|
|
aria-label="Remove item"
|
|
title="Remove item"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<div className={styles.itemGrid}>
|
|
<div className={tableStyles.field}>
|
|
<label>Title</label>
|
|
<input
|
|
value={item.title}
|
|
onChange={(e) => updateDraftItem(item.key, { title: e.target.value })}
|
|
placeholder="Service title"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Duration</label>
|
|
<input
|
|
value={item.duration}
|
|
onChange={(e) => updateDraftItem(item.key, { duration: e.target.value })}
|
|
placeholder="e.g. 3 months"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Worktime</label>
|
|
<input
|
|
value={item.worktime}
|
|
onChange={(e) => updateDraftItem(item.key, { worktime: e.target.value })}
|
|
placeholder="e.g. 40 hours"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Price (IRT)</label>
|
|
<input
|
|
inputMode="numeric"
|
|
value={item.price}
|
|
onChange={(e) =>
|
|
updateDraftItem(item.key, { price: formatIrtInput(e.target.value) })
|
|
}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Discounted price (IRT)</label>
|
|
<input
|
|
inputMode="numeric"
|
|
value={item.discountedPrice}
|
|
onChange={(e) =>
|
|
updateDraftItem(item.key, {
|
|
discountedPrice: formatIrtInput(e.target.value),
|
|
})
|
|
}
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${styles.descField}`}>
|
|
<label>Description</label>
|
|
<textarea
|
|
rows={2}
|
|
value={item.description}
|
|
onChange={(e) => updateDraftItem(item.key, { description: e.target.value })}
|
|
placeholder="Optional details"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className={styles.itemActions}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={addCustomItem}
|
|
>
|
|
<Pencil size={16} />
|
|
Add custom item
|
|
</button>
|
|
<div className={styles.createTotal}>Total: {formatIrtPrice(createTotal)}</div>
|
|
</div>
|
|
|
|
{formError ? <p className={styles.alertError}>{formError}</p> : null}
|
|
|
|
<div className={tableStyles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => setCreateOpen(false)}
|
|
disabled={submitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={() => void handleCreate()}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? 'Issuing…' : 'Issue invoice'}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={!!detailInvoice}
|
|
title={
|
|
detailInvoice
|
|
? detailInvoice.name || `Invoice · ${formatDate(detailInvoice.issuedAt)}`
|
|
: 'Invoice'
|
|
}
|
|
onClose={() => setDetailInvoice(null)}
|
|
xl
|
|
>
|
|
{detailInvoice ? (
|
|
<>
|
|
<div className={styles.detailMeta}>
|
|
<div>
|
|
<span className={styles.detailLabel}>Status</span>
|
|
<select
|
|
value={detailInvoice.status}
|
|
disabled={statusUpdating}
|
|
onChange={(e) => void handleStatusChange(e.target.value as InvoiceStatus)}
|
|
>
|
|
{STATUS_OPTIONS.map((option) => (
|
|
<option key={option} value={option}>
|
|
{statusLabel(option)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<span className={styles.detailLabel}>Total</span>
|
|
<strong>{formatIrtPrice(detailInvoice.total ?? 0)}</strong>
|
|
</div>
|
|
<div>
|
|
<span className={styles.detailLabel}>Public link</span>
|
|
<div className={styles.linkRow}>
|
|
<a
|
|
className={styles.publicLink}
|
|
href={invoicePublicUrl(detailInvoice)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{invoicePublicUrl(detailInvoice).replace(/^https?:\/\//, '')}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
className={styles.copyLinkBtn}
|
|
onClick={() => void copyPublicLink(detailInvoice)}
|
|
title="Copy link"
|
|
aria-label="Copy invoice link"
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{detailInvoice.notes ? (
|
|
<p className={styles.detailNotes}>{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>Duration: {item.duration}</span> : null}
|
|
{item.worktime ? <span>Worktime: {item.worktime}</span> : null}
|
|
</div>
|
|
{item.description ? (
|
|
<p className={styles.detailItemDesc}>{item.description}</p>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</Modal>
|
|
|
|
<ConfirmDeleteModal
|
|
open={!!removeTarget}
|
|
title="Remove invoice"
|
|
message="Remove this invoice permanently? This cannot be undone."
|
|
onCancel={() => setRemoveTarget(null)}
|
|
onConfirm={() => void handleRemove()}
|
|
/>
|
|
</main>
|
|
)
|
|
}
|