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(null) const [invoices, setInvoices] = useState([]) const [templates, setTemplates] = useState([]) 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([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(null) const [statusUpdating, setStatusUpdating] = useState(false) const [removeTarget, setRemoveTarget] = useState(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) { 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 (
Back to businesses

Invoices · {businessName}

List invoices issued to this business, or create a new one from predefined or custom items.

{error ?

{error}

: null}
Invoices
{loading ? 'Loading…' : `${total} invoice${total === 1 ? '' : 's'}`}
{!loading && invoices.length === 0 ? ( ) : null} {invoices.map((invoice) => ( ))}
Name Issued Status Total Link Actions
No invoices yet for this business.
{invoice.name || `Invoice #${invoice.id}`}
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
{formatDate(invoice.issuedAt)} {statusLabel(invoice.status)} {formatIrtPrice(invoice.total ?? 0)}
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
{totalPages > 1 ? (
Page {page} / {totalPages}
) : null} !submitting && setCreateOpen(false)} xl >
setInvoiceName(e.target.value)} placeholder="e.g. Website redesign package" />
setNotes(e.target.value)} placeholder="Optional notes for this invoice" />
{templates.length === 0 ? (

No predefined items yet. Manage them in{' '} Settings → Invoices, or add custom lines below.

) : null}
{draftItems.map((item, index) => (
Item {index + 1}
updateDraftItem(item.key, { title: e.target.value })} placeholder="Service title" />
updateDraftItem(item.key, { duration: e.target.value })} placeholder="e.g. 3 months" />
updateDraftItem(item.key, { worktime: e.target.value })} placeholder="e.g. 40 hours" />
updateDraftItem(item.key, { price: formatIrtInput(e.target.value) }) } placeholder="0" />
updateDraftItem(item.key, { discountedPrice: formatIrtInput(e.target.value), }) } placeholder="Optional" />