Add business Finance hub with invoices and templates.

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>
This commit is contained in:
Alireza Hassani
2026-08-11 15:17:03 +03:30
co-authored by Cursor
parent 722933dda9
commit 917b840ee5
44 changed files with 5136 additions and 105 deletions
+18 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { MessageSquare, Pencil, Plus, Receipt, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
@@ -70,6 +71,7 @@ function teamRoleMessageKey(teamRole: string): BusinessMessageKey {
export function CustomersPage() {
const t = useT()
const navigate = useNavigate()
const { locale } = useLocale()
const { user: authUser } = useAuth()
const { showToast } = useToast()
@@ -243,6 +245,10 @@ export function CustomersPage() {
}
}
function handleViewInvoices(customer: BusinessCustomerListItem) {
navigate(`/invoices?userId=${customer.id}`, { state: { presetCustomer: customer } })
}
function handleSendSms(customer: BusinessCustomerListItem) {
showToast(
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
@@ -352,8 +358,7 @@ export function CustomersPage() {
<option value="managers">{t('customers.filter.access.managers')}</option>
</select>
</div>
<div className={filterStyles.filterSpacerCol4} aria-hidden="true" />
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsCol1}`}>
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsEnd}`}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
@@ -532,6 +537,16 @@ export function CustomersPage() {
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.invoices')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => handleViewInvoices(customer)}
aria-label={t('customers.invoices')}
>
<Receipt size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.sms')}>
<button
type="button"
+60
View File
@@ -0,0 +1,60 @@
import { FileText, ArrowLeftRight } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
const financeSections: {
icon: typeof FileText
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
href: string
}[] = [
{
icon: FileText,
titleKey: 'nav.finance.invoices',
descKey: 'finance.card.invoices.desc',
href: '/invoices',
},
{
icon: ArrowLeftRight,
titleKey: 'nav.finance.transactions',
descKey: 'finance.card.transactions.desc',
href: '/transactions',
},
]
export function FinancePage() {
const t = useT()
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('title.finance') },
]}
/>
<div className={styles.pageHeader}>
<div>
<PageTitle en="FINANCE MANAGEMENT">{t('title.finance')}</PageTitle>
<p className={styles.pageSubtitle}>{t('finance.page.subtitle')}</p>
</div>
</div>
<div className={styles.gridHome}>
{financeSections.map((section) => (
<SectionCard
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
href={section.href}
/>
))}
</div>
</main>
)
}
+8 -8
View File
@@ -6,7 +6,7 @@ import {
Users,
FileText,
Briefcase,
Globe,
Wallet,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
@@ -69,6 +69,13 @@ const sections: {
href: '/customers',
countKey: 'customers',
},
{
icon: Wallet,
titleKey: 'home.card.finance.title',
descKey: 'home.card.finance.desc',
linkKey: 'home.card.finance.link',
href: '/finance',
},
{
icon: FileText,
titleKey: 'home.card.blog.title',
@@ -89,13 +96,6 @@ const sections: {
countKey: 'portfolios',
moduleId: 'portfolio',
},
{
icon: Globe,
titleKey: 'home.card.website.title',
descKey: 'home.card.website.desc',
linkKey: 'home.card.website.link',
href: '/website',
},
]
type SectionCounts = Partial<Record<CountKey, number>>
@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
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 {
createInvoiceTemplate,
getInvoiceTemplate,
listInvoiceItemTemplates,
updateInvoiceTemplate,
} from '../services/invoiceService'
import type { InvoiceItemTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildTemplateItemsPayload,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
type DraftKeyPoint,
type DraftLineItem,
} from '../utils/invoiceDraft'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import tableStyles from './InvoicesPage.module.css'
export function InvoiceTemplateEditorPage() {
const { templateId } = useParams()
const isEdit = Boolean(templateId)
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [loading, setLoading] = useState(isEdit)
const [error, setError] = useState('')
const [name, setName] = useState('')
const [topText, setTopText] = useState('')
const [items, setItems] = 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('')
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const itemsRes = await listInvoiceItemTemplates(controller.signal)
setItemTemplates(itemsRes.items.filter((i) => i.isActive))
if (templateId) {
const template = await getInvoiceTemplate(templateId, controller.signal)
const drafts = draftsFromInvoiceTemplate(template)
setName(drafts.name)
setTopText(drafts.topText)
setItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
}
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.loadError'))
} finally {
setLoading(false)
}
}
void load()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId])
async function handleSave() {
setFormError('')
if (!name.trim()) {
setFormError(t('invoiceTemplateEditor.nameRequired'))
return
}
let lineItems
try {
lineItems = buildTemplateItemsPayload(items, t)
} catch (err) {
setFormError(err instanceof Error ? err.message : t('invoiceTemplateEditor.saveError'))
return
}
const payload = {
name: name.trim(),
topText: isEmptyRichText(topText) ? undefined : topText,
items: lineItems,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
}
setSubmitting(true)
try {
if (templateId) {
await updateInvoiceTemplate(templateId, payload)
showToast(t('invoiceTemplateEditor.updated'), 'success')
} else {
await createInvoiceTemplate(payload)
showToast(t('invoiceTemplateEditor.created'), 'success')
}
navigate('/invoices/templates')
} catch (err) {
setFormError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.saveError'))
} finally {
setSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<Link to="/invoices/templates" className={formStyles.backLink}>
<ArrowLeft size={16} />
{t('invoiceTemplateEditor.backToTemplates')}
</Link>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('invoiceTemplateEditor.editTitle') : t('invoiceTemplateEditor.addTitle')}
</h2>
<p className={pageStyles.pageSubtitle}>{t('invoiceTemplateEditor.subtitle')}</p>
</div>
</div>
{error ? <p className={formStyles.alertError}>{error}</p> : null}
{loading ? <p className={tableStyles.meta}>{t('invoiceTemplateEditor.loading')}</p> : null}
{!loading ? (
<section className={formStyles.section}>
<div className={formStyles.metaGrid}>
<div className={`${formStyles.field} ${formStyles.span2}`}>
<label htmlFor="tpl-name">{t('invoiceTemplateEditor.name')}</label>
<input
id="tpl-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('invoiceTemplateEditor.namePlaceholder')}
/>
</div>
</div>
<div className={`${formStyles.field} ${formStyles.fullField}`}>
<label>{t('invoiceTemplateEditor.topText')}</label>
<RichTextEditor
value={topText}
onChange={setTopText}
placeholder={t('invoiceTemplateEditor.topTextPlaceholder')}
editorMinHeight={88}
/>
</div>
<InvoiceDraftFields
itemTemplates={itemTemplates}
items={items}
keyPoints={keyPoints}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
onItemsChange={setItems}
onKeyPointsChange={setKeyPoints}
onAccountsChange={setAccounts}
/>
{formError ? <p className={formStyles.alertError}>{formError}</p> : null}
<div className={formStyles.actionsRow}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => navigate('/invoices/templates')}
disabled={submitting}
>
{t('invoiceTemplateEditor.cancel')}
</button>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => void handleSave()}
disabled={submitting}
>
{submitting
? t('invoiceTemplateEditor.saving')
: isEdit
? t('invoiceTemplateEditor.save')
: t('invoiceTemplateEditor.create')}
</button>
</div>
</section>
) : null}
</main>
)
}
@@ -0,0 +1,117 @@
.sectionHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.sectionTitleRow {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--primary);
}
.sectionTitle {
margin: 0 0 2px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.sectionSubtitle {
margin: 0;
font-size: 12.5px;
color: var(--text-secondary);
max-width: min(560px, 100%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.emptyState {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 4px;
color: var(--text-secondary);
font-size: 13px;
}
.topTextPreview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin-top: 16px;
font-size: 12.5px;
color: var(--text-secondary);
}
.hint a {
color: var(--primary);
font-weight: 600;
}
.hint a:hover {
text-decoration: underline;
}
/* Invoice templates list: name+subtext ~ half width (6/12) */
.invoiceTemplatesTable col.nameCol {
width: 50%;
}
.invoiceTemplatesTable col.countCol {
width: 12%;
}
.invoiceTemplatesTable col.actionsCol {
width: 14%;
}
/* Item templates: title+desc ~ half width (6/12), rest share the other half */
.itemTemplatesTable col.nameCol {
width: 50%;
}
.itemTemplatesTable col.durationCol,
.itemTemplatesTable col.worktimeCol {
width: 10%;
}
.itemTemplatesTable col.priceCol,
.itemTemplatesTable col.discountedCol {
width: 11%;
}
.itemTemplatesTable col.actionsCol {
width: 8%;
}
.nameCell {
min-width: 0;
}
.nameCell .subText,
.nameCellDesc,
.nameCell .topTextPreview {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
word-break: break-word;
line-height: 1.4;
margin-top: 2px;
max-width: none;
}
@media (max-width: 720px) {
.sectionHeader {
flex-direction: column;
}
}
@@ -0,0 +1,542 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { PageTitle } from '../components/PageTitle'
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
createInvoiceItemTemplate,
deleteInvoiceItemTemplate,
deleteInvoiceTemplate,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateInvoiceItemTemplate,
} from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import tableStyles from './InvoicesPage.module.css'
import styles from './InvoiceTemplatesPage.module.css'
type ItemDraft = {
title: string
duration: string
worktime: string
description: string
price: string
discountedPrice: string
}
const EMPTY_ITEM_DRAFT: ItemDraft = {
title: '',
duration: '',
worktime: '',
description: '',
price: '',
discountedPrice: '',
}
function itemDraftFromTemplate(template: InvoiceItemTemplate): ItemDraft {
return {
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))),
}
}
export function InvoiceTemplatesPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [itemEditorOpen, setItemEditorOpen] = useState(false)
const [editingItem, setEditingItem] = useState<InvoiceItemTemplate | null>(null)
const [itemDraft, setItemDraft] = useState<ItemDraft>(EMPTY_ITEM_DRAFT)
const [itemSubmitting, setItemSubmitting] = useState(false)
const [itemFormError, setItemFormError] = useState('')
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | null>(null)
function itemDraftToPayload(draft: ItemDraft) {
const price = parseIrtInput(draft.price)
if (!draft.title.trim()) throw new Error(t('invoiceDraft.error.itemTitle'))
if (price === null) throw new Error(t('invoiceDraft.error.itemPrice', { title: draft.title.trim() }))
const discountedPrice = draft.discountedPrice.trim() ? parseIrtInput(draft.discountedPrice) : null
if (draft.discountedPrice.trim() && discountedPrice === null) {
throw new Error(t('invoiceDraft.error.itemDiscountInvalid', { title: draft.title.trim() }))
}
if (discountedPrice !== null && discountedPrice > price) {
throw new Error(t('invoiceDraft.error.itemDiscountExceeds', { title: draft.title.trim() }))
}
return {
title: draft.title.trim(),
duration: draft.duration.trim() || undefined,
worktime: draft.worktime.trim() || undefined,
description: draft.description.trim() || undefined,
price,
discountedPrice,
}
}
async function reload(signal?: AbortSignal) {
setLoading(true)
setError('')
try {
const [items, templates] = await Promise.all([
listInvoiceItemTemplates(signal),
listInvoiceTemplates(signal),
])
setItemTemplates(items.items)
setInvoiceTemplates(templates.items)
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('invoiceTemplates.loadError'))
} finally {
setLoading(false)
}
}
useEffect(() => {
const controller = new AbortController()
void reload(controller.signal)
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function openCreateItem() {
setEditingItem(null)
setItemDraft(EMPTY_ITEM_DRAFT)
setItemFormError('')
setItemEditorOpen(true)
}
function openEditItem(template: InvoiceItemTemplate) {
setEditingItem(template)
setItemDraft(itemDraftFromTemplate(template))
setItemFormError('')
setItemEditorOpen(true)
}
async function handleSaveItem() {
setItemFormError('')
let payload
try {
payload = itemDraftToPayload(itemDraft)
} catch (err) {
setItemFormError(err instanceof Error ? err.message : t('invoiceTemplates.itemSaveError'))
return
}
setItemSubmitting(true)
try {
if (editingItem) {
await updateInvoiceItemTemplate(editingItem.id, payload)
showToast(t('invoiceTemplates.itemUpdated'), 'success')
} else {
await createInvoiceItemTemplate(payload)
showToast(t('invoiceTemplates.itemCreated'), 'success')
}
setItemEditorOpen(false)
await reload()
} catch (err) {
setItemFormError(err instanceof ApiError ? err.message : t('invoiceTemplates.itemSaveError'))
} finally {
setItemSubmitting(false)
}
}
async function handleRemoveItem() {
if (!removeItemTarget) return
try {
await deleteInvoiceItemTemplate(removeItemTarget.id)
showToast(t('invoiceTemplates.itemRemoved'), 'success')
setRemoveItemTarget(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoiceTemplates.itemRemoveError'), 'error')
}
}
async function handleRemoveTpl() {
if (!removeTplTarget) return
try {
await deleteInvoiceTemplate(removeTplTarget.id)
showToast(t('invoiceTemplates.templateRemoved'), 'success')
setRemoveTplTarget(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoiceTemplates.templateRemoveError'), 'error')
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('nav.finance'), href: '/finance' },
{ label: t('title.invoices'), href: '/invoices' },
{ label: t('title.invoiceTemplates') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="INVOICE TEMPLATES">{t('title.invoiceTemplates')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('invoiceTemplates.subtitle')}</p>
</div>
</div>
{error ? <p className={formStyles.alertError}>{error}</p> : null}
<section className={formStyles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitleRow}>
<FileStack size={18} />
<div>
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.templates.title')}</h3>
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.templates.desc')}</p>
</div>
</div>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => navigate('/invoices/templates/new')}
>
<Plus size={16} />
{t('invoiceTemplates.addTemplate')}
</button>
</div>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.meta}>
{loading
? t('invoices.loading')
: invoiceTemplates.length === 1
? t('invoiceTemplates.countTemplatesOne', { count: invoiceTemplates.length })
: t('invoiceTemplates.countTemplatesMany', { count: invoiceTemplates.length })}
</div>
</div>
<table className={`${tableStyles.table} ${styles.invoiceTemplatesTable}`}>
<colgroup>
<col className={styles.nameCol} />
<col className={styles.countCol} />
<col className={styles.countCol} />
<col className={styles.countCol} />
<col className={styles.actionsCol} />
</colgroup>
<thead>
<tr>
<th className={tableStyles.th}>{t('invoiceTemplates.col.name')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.items')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.keyPoints')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.accounts')}</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
{t('invoiceTemplates.col.actions')}
</th>
</tr>
</thead>
<tbody>
{!loading && invoiceTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={5}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>{t('invoiceTemplates.emptyTemplates')}</span>
</div>
</td>
</tr>
) : null}
{invoiceTemplates.map((template) => (
<tr key={template.id}>
<td className={`${tableStyles.td} ${styles.nameCell}`}>
<div className={tableStyles.itemTitle}>{template.name}</div>
{template.topText && !isEmptyRichText(template.topText) ? (
<div
className={`${tableStyles.subText} ${styles.topTextPreview}`}
title={richTextToPlain(template.topText)}
>
{richTextToPlain(template.topText)}
</div>
) : null}
</td>
<td className={tableStyles.td}>{template.items.length}</td>
<td className={tableStyles.td}>{template.keyPoints.length}</td>
<td className={tableStyles.td}>{template.accounts.length}</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<Tooltip label={t('invoiceTemplates.editTemplate')}>
<button
type="button"
className={tableStyles.actionBtn}
onClick={() => navigate(`/invoices/templates/${template.id}`)}
aria-label={t('invoiceTemplates.editTemplate')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('invoiceTemplates.removeTemplate')}>
<button
type="button"
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
onClick={() => setRemoveTplTarget(template)}
aria-label={t('invoiceTemplates.removeTemplate')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={`${formStyles.section} ${formStyles.sectionSpaced}`}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitleRow}>
<FileText size={18} />
<div>
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.items.title')}</h3>
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.items.desc')}</p>
</div>
</div>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={openCreateItem}
>
<Plus size={16} />
{t('invoiceTemplates.addItem')}
</button>
</div>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.meta}>
{loading
? t('invoices.loading')
: itemTemplates.length === 1
? t('invoiceTemplates.countItemsOne', { count: itemTemplates.length })
: t('invoiceTemplates.countItemsMany', { count: itemTemplates.length })}
</div>
</div>
<table className={`${tableStyles.table} ${styles.itemTemplatesTable}`}>
<colgroup>
<col className={styles.nameCol} />
<col className={styles.durationCol} />
<col className={styles.worktimeCol} />
<col className={styles.priceCol} />
<col className={styles.discountedCol} />
<col className={styles.actionsCol} />
</colgroup>
<thead>
<tr>
<th className={tableStyles.th}>{t('invoiceTemplates.col.title')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.duration')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.worktime')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.price')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.discounted')}</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
{t('invoiceTemplates.col.actions')}
</th>
</tr>
</thead>
<tbody>
{!loading && itemTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={6}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>{t('invoiceTemplates.emptyItems')}</span>
</div>
</td>
</tr>
) : null}
{itemTemplates.map((template) => (
<tr key={template.id}>
<td className={`${tableStyles.td} ${styles.nameCell}`}>
<div className={tableStyles.itemTitle}>{template.title}</div>
{template.description ? (
<div className={`${tableStyles.subText} ${styles.nameCellDesc}`}>
{template.description}
</div>
) : null}
</td>
<td className={tableStyles.td}>{template.duration || '—'}</td>
<td className={tableStyles.td}>{template.worktime || '—'}</td>
<td className={tableStyles.td}>{formatIrtPrice(template.price)}</td>
<td className={tableStyles.td}>
{template.discountedPrice === null ? '—' : formatIrtPrice(template.discountedPrice)}
</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<Tooltip label={t('invoiceTemplates.editItem')}>
<button
type="button"
className={tableStyles.actionBtn}
onClick={() => openEditItem(template)}
aria-label={t('invoiceTemplates.editItem')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('invoiceTemplates.removeItem')}>
<button
type="button"
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
onClick={() => setRemoveItemTarget(template)}
aria-label={t('invoiceTemplates.removeItem')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p className={styles.hint}>{t('invoiceTemplates.hint')}</p>
<Modal
open={itemEditorOpen}
title={editingItem ? t('invoiceTemplates.itemModalEditTitle') : t('invoiceTemplates.itemModalAddTitle')}
onClose={() => !itemSubmitting && setItemEditorOpen(false)}
wide
>
<div className={formStyles.formGrid}>
<div className={`${formStyles.field} ${formStyles.span2}`}>
<label htmlFor="item-title">{t('invoiceDraft.fieldTitle')}</label>
<input
id="item-title"
value={itemDraft.title}
onChange={(e) => setItemDraft((d) => ({ ...d, title: e.target.value }))}
placeholder={t('invoiceDraft.fieldTitlePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-duration">{t('invoiceDraft.fieldDuration')}</label>
<input
id="item-duration"
value={itemDraft.duration}
onChange={(e) => setItemDraft((d) => ({ ...d, duration: e.target.value }))}
placeholder={t('invoiceDraft.fieldDurationPlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-worktime">{t('invoiceDraft.fieldWorktime')}</label>
<input
id="item-worktime"
value={itemDraft.worktime}
onChange={(e) => setItemDraft((d) => ({ ...d, worktime: e.target.value }))}
placeholder={t('invoiceDraft.fieldWorktimePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-price">{t('invoiceDraft.fieldPrice')}</label>
<input
id="item-price"
inputMode="numeric"
value={itemDraft.price}
onChange={(e) => setItemDraft((d) => ({ ...d, price: formatIrtInput(e.target.value) }))}
placeholder="0"
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-discount">{t('invoiceDraft.fieldDiscountedPrice')}</label>
<input
id="item-discount"
inputMode="numeric"
value={itemDraft.discountedPrice}
onChange={(e) =>
setItemDraft((d) => ({ ...d, discountedPrice: formatIrtInput(e.target.value) }))
}
placeholder={t('invoiceDraft.optional')}
/>
</div>
<div className={`${formStyles.field} ${formStyles.span2}`}>
<label htmlFor="item-desc">{t('invoiceDraft.fieldDescription')}</label>
<textarea
id="item-desc"
rows={3}
value={itemDraft.description}
onChange={(e) => setItemDraft((d) => ({ ...d, description: e.target.value }))}
placeholder={t('invoiceDraft.fieldDescriptionPlaceholder')}
/>
</div>
</div>
{itemFormError ? <p className={formStyles.alertError}>{itemFormError}</p> : null}
<div className={formStyles.actionsRow}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => setItemEditorOpen(false)}
disabled={itemSubmitting}
>
{t('invoiceTemplates.cancel')}
</button>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => void handleSaveItem()}
disabled={itemSubmitting}
>
{itemSubmitting
? t('invoiceTemplates.savingItem')
: editingItem
? t('invoiceTemplates.saveItem')
: t('invoiceTemplates.createItem')}
</button>
</div>
</Modal>
<ConfirmDeleteModal
open={!!removeTplTarget}
title={t('invoiceTemplates.deleteTemplateTitle')}
message={
removeTplTarget
? t('invoiceTemplates.deleteTemplateMessage', { name: removeTplTarget.name })
: ''
}
onCancel={() => setRemoveTplTarget(null)}
onConfirm={() => void handleRemoveTpl()}
/>
<ConfirmDeleteModal
open={!!removeItemTarget}
title={t('invoiceTemplates.deleteItemTitle')}
message={
removeItemTarget
? t('invoiceTemplates.deleteItemMessage', { title: removeItemTarget.title })
: ''
}
onCancel={() => setRemoveItemTarget(null)}
onConfirm={() => void handleRemoveItem()}
/>
</main>
)
}
@@ -0,0 +1,531 @@
.tablePanel {
margin-top: 12px;
padding: 0;
background: transparent;
border-radius: var(--radius);
overflow: hidden;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.tableHeader {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: flex-end;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
}
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
/* 12-col feel: name ≈ 4, customer ≈ 2, rest share remaining */
.colName {
width: 33.333%;
}
.colCustomer {
width: 16%;
}
.colIssued {
width: 11%;
}
.colStatus {
width: 10%;
}
.colTotal {
width: 11%;
}
.colLink {
width: 12%;
}
.colActions {
width: 112px;
}
.th,
.td {
padding: 10px 12px;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
min-width: 0;
}
.th {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
}
.td {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.subText {
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemTitle {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tdCustomer {
vertical-align: top;
}
.customerStack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
max-width: 100%;
}
.customerCell {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.customerPhone {
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.recipientHeading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 4px;
}
.recipientPrefix {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.recipientName {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.recipientPhoneChip {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.14);
border: 1px solid rgba(148, 163, 184, 0.28);
font-variant-numeric: tabular-nums;
}
.thActions,
.tdActions {
text-align: end;
white-space: nowrap;
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.actionBtn {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.actionBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.actionBtnDanger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.actionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination {
padding: 12px 16px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
min-height: 38px;
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
box-sizing: border-box;
}
.pagination > div:first-child {
grid-column: 1;
justify-self: start;
}
.pagination > nav {
grid-column: 2;
justify-self: center;
}
.statusChip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid transparent;
transition: filter 0.2s;
font-family: var(--font-ui);
}
.statusChipBtn {
cursor: pointer;
background: none;
}
.statusChipBtn:hover {
filter: brightness(0.95);
}
.statusChipBtn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.statusChipBtn:disabled {
cursor: wait;
opacity: 0.7;
}
.statusOptionList {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.statusOption {
min-height: 32px;
padding: 6px 12px;
font-size: 12px;
}
.statusOptionSelected {
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.status_draft,
.status_issued {
color: #0f172a;
background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.18);
}
.status_approved {
color: #0e7490;
background: rgba(6, 182, 212, 0.14);
border-color: rgba(6, 182, 212, 0.32);
}
.status_paid {
color: #15803d;
background: rgba(34, 197, 94, 0.14);
border-color: rgba(34, 197, 94, 0.32);
}
.status_cancelled {
color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.28);
}
.publicLink {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--primary);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.publicLink:hover {
text-decoration: underline;
}
.linkRow {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.copyLinkBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
background: rgba(var(--primary-rgb) / 0.06);
border: 1px solid rgba(var(--primary-rgb) / 0.14);
flex-shrink: 0;
}
.copyLinkBtn:hover {
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
}
.filterBanner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding: 10px 14px;
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-primary);
}
.filterBannerActions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.filterBannerCancel {
flex-shrink: 0;
margin-inline-start: auto;
padding: 0;
border: none;
background: none;
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
color: var(--primary);
cursor: pointer;
}
.filterBannerCancel:hover {
text-decoration: underline;
}
.filterBannerClear {
font-size: 13px;
font-weight: 600;
color: var(--primary);
}
.filterBannerClear:hover {
text-decoration: underline;
}
.detailMeta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.detailLabel {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 2px;
}
.detailMeta .statusChipBtn {
margin-top: 4px;
}
.detailNotes {
margin: 0 0 14px;
font-size: 13px;
color: var(--text-secondary);
}
.detailItems {
display: flex;
flex-direction: column;
gap: 10px;
}
.detailItem {
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.4);
}
.detailItemTop {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 14px;
}
.detailItemMeta {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.detailItemDesc {
margin: 8px 0 0;
font-size: 13px;
color: var(--text-secondary);
}
.strike {
text-decoration: line-through;
color: var(--text-muted);
font-weight: 500;
}
.blockSection {
margin: 16px 0;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.22);
}
.blockTitle {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.keyPointList {
margin: 0;
padding-inline-start: 18px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 900px) {
.detailMeta {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.detailMeta {
grid-template-columns: 1fr;
}
}
.fab {
position: fixed;
inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px;
z-index: 110;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.fab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (max-width: 768px) {
.fab {
inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px;
width: 52px;
height: 52px;
}
}
+602
View File
@@ -0,0 +1,602 @@
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) {
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>
)
}
@@ -0,0 +1,413 @@
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>
)
}
@@ -0,0 +1,34 @@
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import formStyles from '../components/InvoiceForm.module.css'
import pageStyles from '../components/PageContent.module.css'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css'
export function TransactionsPage() {
const t = useT()
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('nav.finance'), href: '/finance' },
{ label: t('title.transactions') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="TRANSACTIONS">{t('title.transactions')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('transactions.subtitle')}</p>
</div>
</div>
<section className={formStyles.section}>
<p className={styles.placeholderLead}>{t('transactions.lead')}</p>
<p className={styles.placeholderNote}>{t('transactions.note')}</p>
</section>
</main>
)
}