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:
co-authored by
Cursor
parent
722933dda9
commit
917b840ee5
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user