Add bank account templates and default invoice payment text.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8465a29ce1
commit
ff419603bf
@@ -9,16 +9,18 @@ import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoiceTemplate,
|
||||
getInvoiceTemplate,
|
||||
listInvoiceAccountTemplates,
|
||||
listInvoiceItemTemplates,
|
||||
updateInvoiceTemplate,
|
||||
} from '../services/invoiceService'
|
||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||
import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
|
||||
import {
|
||||
buildAccountsPayload,
|
||||
buildKeyPointsPayload,
|
||||
buildPaymentMethodsPayload,
|
||||
buildTemplateItemsPayload,
|
||||
draftsFromInvoiceTemplate,
|
||||
defaultDraftPaymentMethods,
|
||||
emptyDraftItem,
|
||||
type DraftAccount,
|
||||
type DraftKeyPoint,
|
||||
@@ -37,15 +39,19 @@ export function InvoiceTemplateEditorPage() {
|
||||
const t = useT()
|
||||
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
|
||||
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 [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
|
||||
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
|
||||
isEdit ? [] : defaultDraftPaymentMethods(),
|
||||
)
|
||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||
const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
@@ -56,8 +62,12 @@ export function InvoiceTemplateEditorPage() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const itemsRes = await listInvoiceItemTemplates(controller.signal)
|
||||
const [itemsRes, accountsRes] = await Promise.all([
|
||||
listInvoiceItemTemplates(controller.signal),
|
||||
listInvoiceAccountTemplates(controller.signal),
|
||||
])
|
||||
setItemTemplates(itemsRes.items.filter((i) => i.isActive))
|
||||
setAccountTemplates(accountsRes.items.filter((i) => i.isActive))
|
||||
|
||||
if (templateId) {
|
||||
const template = await getInvoiceTemplate(templateId, controller.signal)
|
||||
@@ -166,12 +176,15 @@ export function InvoiceTemplateEditorPage() {
|
||||
|
||||
<InvoiceDraftFields
|
||||
itemTemplates={itemTemplates}
|
||||
accountTemplates={accountTemplates}
|
||||
items={items}
|
||||
keyPoints={keyPoints}
|
||||
paymentMethods={paymentMethods}
|
||||
accounts={accounts}
|
||||
selectedItemTemplateId={selectedItemTemplateId}
|
||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||
selectedAccountTemplateId={selectedAccountTemplateId}
|
||||
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
|
||||
onItemsChange={setItems}
|
||||
onKeyPointsChange={setKeyPoints}
|
||||
onPaymentMethodsChange={setPaymentMethods}
|
||||
|
||||
@@ -3,7 +3,35 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sectionToggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sectionChevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.18s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionChevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sectionTitleRow {
|
||||
@@ -11,9 +39,11 @@
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: block;
|
||||
margin: 0 0 2px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
@@ -21,6 +51,7 @@
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
@@ -30,6 +61,26 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sectionAction {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.topTextPreview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -83,6 +134,26 @@
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.accountTemplatesTable col.nameCol {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.accountTemplatesTable col.holderCol {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.accountTemplatesTable col.cardCol {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.accountTemplatesTable col.ibanCol {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.accountTemplatesTable col.actionsCol {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.nameCell {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -105,4 +176,8 @@
|
||||
.sectionHeader {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sectionAction {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FileStack, FileText, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { ChevronDown, FileStack, FileText, Landmark, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { EmptyTableRow } from '../components/EmptyState'
|
||||
@@ -12,20 +12,30 @@ import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoiceAccountTemplate,
|
||||
createInvoiceItemTemplate,
|
||||
deleteInvoiceAccountTemplate,
|
||||
deleteInvoiceItemTemplate,
|
||||
deleteInvoiceTemplate,
|
||||
listInvoiceAccountTemplates,
|
||||
listInvoiceItemTemplates,
|
||||
listInvoiceTemplates,
|
||||
updateInvoiceAccountTemplate,
|
||||
updateInvoiceItemTemplate,
|
||||
} from '../services/invoiceService'
|
||||
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
|
||||
import type {
|
||||
InvoiceAccountTemplate,
|
||||
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 SectionId = 'templates' | 'items' | 'accounts'
|
||||
|
||||
type ItemDraft = {
|
||||
title: string
|
||||
duration: string
|
||||
@@ -35,6 +45,13 @@ type ItemDraft = {
|
||||
discountedPrice: string
|
||||
}
|
||||
|
||||
type AccountDraft = {
|
||||
bankName: string
|
||||
accountHolderName: string
|
||||
cardNumber: string
|
||||
iban: string
|
||||
}
|
||||
|
||||
const EMPTY_ITEM_DRAFT: ItemDraft = {
|
||||
title: '',
|
||||
duration: '',
|
||||
@@ -44,6 +61,13 @@ const EMPTY_ITEM_DRAFT: ItemDraft = {
|
||||
discountedPrice: '',
|
||||
}
|
||||
|
||||
const EMPTY_ACCOUNT_DRAFT: AccountDraft = {
|
||||
bankName: '',
|
||||
accountHolderName: '',
|
||||
cardNumber: '',
|
||||
iban: '',
|
||||
}
|
||||
|
||||
function itemDraftFromTemplate(template: InvoiceItemTemplate): ItemDraft {
|
||||
return {
|
||||
title: template.title,
|
||||
@@ -58,12 +82,78 @@ function itemDraftFromTemplate(template: InvoiceItemTemplate): ItemDraft {
|
||||
}
|
||||
}
|
||||
|
||||
function accountDraftFromTemplate(template: InvoiceAccountTemplate): AccountDraft {
|
||||
return {
|
||||
bankName: template.bankName,
|
||||
accountHolderName: template.accountHolderName ?? '',
|
||||
cardNumber: template.cardNumber ?? '',
|
||||
iban: template.iban ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
id,
|
||||
open,
|
||||
onToggle,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
id: SectionId
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
icon: ReactNode
|
||||
title: string
|
||||
subtitle: string
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const t = useT()
|
||||
return (
|
||||
<section className={`${formStyles.section} ${id !== 'templates' ? formStyles.sectionSpaced : ''}`}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sectionToggle}
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={`invoice-templates-section-${id}`}
|
||||
>
|
||||
<span className={`${styles.sectionChevron} ${open ? styles.sectionChevronOpen : ''}`}>
|
||||
<ChevronDown size={18} />
|
||||
</span>
|
||||
<span className={styles.sectionTitleRow}>
|
||||
{icon}
|
||||
<span>
|
||||
<span className={styles.sectionTitle}>{title}</span>
|
||||
<span className={styles.sectionSubtitle}>{subtitle}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.srOnly}>
|
||||
{open ? t('invoiceTemplates.collapseSection') : t('invoiceTemplates.expandSection')}
|
||||
</span>
|
||||
</button>
|
||||
{open && action ? <div className={styles.sectionAction}>{action}</div> : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div id={`invoice-templates-section-${id}`} className={styles.sectionBody}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function InvoiceTemplatesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
|
||||
const [openSection, setOpenSection] = useState<SectionId>('templates')
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
|
||||
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -76,6 +166,17 @@ export function InvoiceTemplatesPage() {
|
||||
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
|
||||
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | null>(null)
|
||||
|
||||
const [accountEditorOpen, setAccountEditorOpen] = useState(false)
|
||||
const [editingAccount, setEditingAccount] = useState<InvoiceAccountTemplate | null>(null)
|
||||
const [accountDraft, setAccountDraft] = useState<AccountDraft>(EMPTY_ACCOUNT_DRAFT)
|
||||
const [accountSubmitting, setAccountSubmitting] = useState(false)
|
||||
const [accountFormError, setAccountFormError] = useState('')
|
||||
const [removeAccountTarget, setRemoveAccountTarget] = useState<InvoiceAccountTemplate | null>(null)
|
||||
|
||||
function toggleSection(id: SectionId) {
|
||||
setOpenSection(id)
|
||||
}
|
||||
|
||||
function itemDraftToPayload(draft: ItemDraft) {
|
||||
const price = parseIrtInput(draft.price)
|
||||
if (!draft.title.trim()) throw new Error(t('invoiceDraft.error.itemTitle'))
|
||||
@@ -97,15 +198,27 @@ export function InvoiceTemplatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function accountDraftToPayload(draft: AccountDraft) {
|
||||
if (!draft.bankName.trim()) throw new Error(t('invoiceTemplates.accountBankRequired'))
|
||||
return {
|
||||
bankName: draft.bankName.trim(),
|
||||
accountHolderName: draft.accountHolderName.trim() || undefined,
|
||||
cardNumber: draft.cardNumber.trim() || undefined,
|
||||
iban: draft.iban.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [items, templates] = await Promise.all([
|
||||
const [items, accounts, templates] = await Promise.all([
|
||||
listInvoiceItemTemplates(signal),
|
||||
listInvoiceAccountTemplates(signal),
|
||||
listInvoiceTemplates(signal),
|
||||
])
|
||||
setItemTemplates(items.items)
|
||||
setAccountTemplates(accounts.items)
|
||||
setInvoiceTemplates(templates.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
@@ -187,6 +300,64 @@ export function InvoiceTemplatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateAccount() {
|
||||
setEditingAccount(null)
|
||||
setAccountDraft(EMPTY_ACCOUNT_DRAFT)
|
||||
setAccountFormError('')
|
||||
setAccountEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEditAccount(template: InvoiceAccountTemplate) {
|
||||
setEditingAccount(template)
|
||||
setAccountDraft(accountDraftFromTemplate(template))
|
||||
setAccountFormError('')
|
||||
setAccountEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSaveAccount() {
|
||||
setAccountFormError('')
|
||||
let payload
|
||||
try {
|
||||
payload = accountDraftToPayload(accountDraft)
|
||||
} catch (err) {
|
||||
setAccountFormError(err instanceof Error ? err.message : t('invoiceTemplates.accountSaveError'))
|
||||
return
|
||||
}
|
||||
setAccountSubmitting(true)
|
||||
try {
|
||||
if (editingAccount) {
|
||||
await updateInvoiceAccountTemplate(editingAccount.id, payload)
|
||||
showToast(t('invoiceTemplates.accountUpdated'), 'success')
|
||||
} else {
|
||||
await createInvoiceAccountTemplate(payload)
|
||||
showToast(t('invoiceTemplates.accountCreated'), 'success')
|
||||
}
|
||||
setAccountEditorOpen(false)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setAccountFormError(
|
||||
err instanceof ApiError ? err.message : t('invoiceTemplates.accountSaveError'),
|
||||
)
|
||||
} finally {
|
||||
setAccountSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveAccount() {
|
||||
if (!removeAccountTarget) return
|
||||
try {
|
||||
await deleteInvoiceAccountTemplate(removeAccountTarget.id)
|
||||
showToast(t('invoiceTemplates.accountRemoved'), 'success')
|
||||
setRemoveAccountTarget(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : t('invoiceTemplates.accountRemoveError'),
|
||||
'error',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -207,15 +378,14 @@ export function InvoiceTemplatesPage() {
|
||||
|
||||
{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>
|
||||
<CollapsibleSection
|
||||
id="templates"
|
||||
open={openSection === 'templates'}
|
||||
onToggle={() => toggleSection('templates')}
|
||||
icon={<FileStack size={18} />}
|
||||
title={t('invoiceTemplates.section.templates.title')}
|
||||
subtitle={t('invoiceTemplates.section.templates.desc')}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
@@ -224,8 +394,8 @@ export function InvoiceTemplatesPage() {
|
||||
<Plus size={16} />
|
||||
{t('invoiceTemplates.addTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.meta}>
|
||||
@@ -310,17 +480,16 @@ export function InvoiceTemplatesPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
|
||||
<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>
|
||||
<CollapsibleSection
|
||||
id="items"
|
||||
open={openSection === 'items'}
|
||||
onToggle={() => toggleSection('items')}
|
||||
icon={<FileText size={18} />}
|
||||
title={t('invoiceTemplates.section.items.title')}
|
||||
subtitle={t('invoiceTemplates.section.items.desc')}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
@@ -329,8 +498,8 @@ export function InvoiceTemplatesPage() {
|
||||
<Plus size={16} />
|
||||
{t('invoiceTemplates.addItem')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.meta}>
|
||||
@@ -417,7 +586,103 @@ export function InvoiceTemplatesPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
id="accounts"
|
||||
open={openSection === 'accounts'}
|
||||
onToggle={() => toggleSection('accounts')}
|
||||
icon={<Landmark size={18} />}
|
||||
title={t('invoiceTemplates.section.accounts.title')}
|
||||
subtitle={t('invoiceTemplates.section.accounts.desc')}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
onClick={openCreateAccount}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('invoiceTemplates.addAccount')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.meta}>
|
||||
{loading
|
||||
? t('invoices.loading')
|
||||
: accountTemplates.length === 1
|
||||
? t('invoiceTemplates.countAccountsOne', { count: accountTemplates.length })
|
||||
: t('invoiceTemplates.countAccountsMany', { count: accountTemplates.length })}
|
||||
</div>
|
||||
</div>
|
||||
<table className={`${tableStyles.table} ${styles.accountTemplatesTable}`}>
|
||||
<colgroup>
|
||||
<col className={styles.nameCol} />
|
||||
<col className={styles.holderCol} />
|
||||
<col className={styles.cardCol} />
|
||||
<col className={styles.ibanCol} />
|
||||
<col className={styles.actionsCol} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.bankName')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.accountHolder')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.cardNumber')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.iban')}</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
|
||||
{t('invoiceTemplates.col.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && accountTemplates.length === 0 ? (
|
||||
<EmptyTableRow
|
||||
colSpan={5}
|
||||
icon={Landmark}
|
||||
message={t('invoiceTemplates.emptyAccounts')}
|
||||
actionLabel={t('invoiceTemplates.addAccount')}
|
||||
onAction={openCreateAccount}
|
||||
/>
|
||||
) : null}
|
||||
{accountTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
<td className={`${tableStyles.td} ${styles.nameCell}`}>
|
||||
<div className={tableStyles.itemTitle}>{template.bankName}</div>
|
||||
</td>
|
||||
<td className={tableStyles.td}>{template.accountHolderName || '—'}</td>
|
||||
<td className={tableStyles.td}>{template.cardNumber || '—'}</td>
|
||||
<td className={tableStyles.td}>{template.iban || '—'}</td>
|
||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||
<div className={tableStyles.rowActions}>
|
||||
<Tooltip label={t('invoiceTemplates.editAccount')}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.actionBtn}
|
||||
onClick={() => openEditAccount(template)}
|
||||
aria-label={t('invoiceTemplates.editAccount')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('invoiceTemplates.removeAccount')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveAccountTarget(template)}
|
||||
aria-label={t('invoiceTemplates.removeAccount')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<p className={styles.hint}>{t('invoiceTemplates.hint')}</p>
|
||||
|
||||
@@ -513,6 +778,79 @@ export function InvoiceTemplatesPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={accountEditorOpen}
|
||||
title={
|
||||
editingAccount
|
||||
? t('invoiceTemplates.accountModalEditTitle')
|
||||
: t('invoiceTemplates.accountModalAddTitle')
|
||||
}
|
||||
onClose={() => !accountSubmitting && setAccountEditorOpen(false)}
|
||||
wide
|
||||
>
|
||||
<div className={formStyles.formGrid}>
|
||||
<div className={`${formStyles.field} ${formStyles.span2}`}>
|
||||
<label htmlFor="account-bank">{t('invoiceDraft.bankName')}</label>
|
||||
<input
|
||||
id="account-bank"
|
||||
value={accountDraft.bankName}
|
||||
onChange={(e) => setAccountDraft((d) => ({ ...d, bankName: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.bankName')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${formStyles.field} ${formStyles.span2}`}>
|
||||
<label htmlFor="account-holder">{t('invoiceDraft.accountHolder')}</label>
|
||||
<input
|
||||
id="account-holder"
|
||||
value={accountDraft.accountHolderName}
|
||||
onChange={(e) => setAccountDraft((d) => ({ ...d, accountHolderName: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.accountHolder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="account-card">{t('invoiceDraft.cardNumber')}</label>
|
||||
<input
|
||||
id="account-card"
|
||||
value={accountDraft.cardNumber}
|
||||
onChange={(e) => setAccountDraft((d) => ({ ...d, cardNumber: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.optional')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="account-iban">{t('invoiceDraft.iban')}</label>
|
||||
<input
|
||||
id="account-iban"
|
||||
value={accountDraft.iban}
|
||||
onChange={(e) => setAccountDraft((d) => ({ ...d, iban: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.optional')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{accountFormError ? <p className={formStyles.alertError}>{accountFormError}</p> : null}
|
||||
<div className={formStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => setAccountEditorOpen(false)}
|
||||
disabled={accountSubmitting}
|
||||
>
|
||||
{t('invoiceTemplates.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
onClick={() => void handleSaveAccount()}
|
||||
disabled={accountSubmitting}
|
||||
>
|
||||
{accountSubmitting
|
||||
? t('invoiceTemplates.savingAccount')
|
||||
: editingAccount
|
||||
? t('invoiceTemplates.saveAccount')
|
||||
: t('invoiceTemplates.createAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTplTarget}
|
||||
title={t('invoiceTemplates.deleteTemplateTitle')}
|
||||
@@ -536,6 +874,18 @@ export function InvoiceTemplatesPage() {
|
||||
onCancel={() => setRemoveItemTarget(null)}
|
||||
onConfirm={() => void handleRemoveItem()}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeAccountTarget}
|
||||
title={t('invoiceTemplates.deleteAccountTitle')}
|
||||
message={
|
||||
removeAccountTarget
|
||||
? t('invoiceTemplates.deleteAccountMessage', { name: removeAccountTarget.bankName })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveAccountTarget(null)}
|
||||
onConfirm={() => void handleRemoveAccount()}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoice,
|
||||
getInvoice,
|
||||
listInvoiceAccountTemplates,
|
||||
listInvoiceItemTemplates,
|
||||
listInvoiceTemplates,
|
||||
updateInvoice,
|
||||
} from '../services/invoiceService'
|
||||
import { listCustomers, type BusinessCustomer } from '../services/customerService'
|
||||
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
|
||||
import type { InvoiceAccountTemplate, InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
|
||||
import {
|
||||
buildAccountsPayload,
|
||||
buildKeyPointsPayload,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
buildPaymentMethodsPayload,
|
||||
draftsFromInvoice,
|
||||
draftsFromInvoiceTemplate,
|
||||
defaultDraftPaymentMethods,
|
||||
emptyDraftItem,
|
||||
type DraftAccount,
|
||||
type DraftKeyPoint,
|
||||
@@ -72,6 +74,7 @@ export function IssueInvoicePage() {
|
||||
?.presetCustomer
|
||||
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
|
||||
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -90,9 +93,12 @@ export function IssueInvoicePage() {
|
||||
const [notes, setNotes] = useState('')
|
||||
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
|
||||
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
|
||||
isEdit ? [] : defaultDraftPaymentMethods(),
|
||||
)
|
||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||
const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
@@ -114,11 +120,13 @@ export function IssueInvoicePage() {
|
||||
setError('')
|
||||
setLocked(false)
|
||||
try {
|
||||
const [items, templates] = await Promise.all([
|
||||
const [items, accountsRes, templates] = await Promise.all([
|
||||
listInvoiceItemTemplates(controller.signal),
|
||||
listInvoiceAccountTemplates(controller.signal),
|
||||
listInvoiceTemplates(controller.signal),
|
||||
])
|
||||
setItemTemplates(items.items.filter((i) => i.isActive))
|
||||
setAccountTemplates(accountsRes.items.filter((i) => i.isActive))
|
||||
setInvoiceTemplates(templates.items.filter((i) => i.isActive))
|
||||
|
||||
if (invoiceId) {
|
||||
@@ -142,6 +150,7 @@ export function IssueInvoicePage() {
|
||||
setPaymentMethods(drafts.paymentMethods)
|
||||
setAccounts(drafts.accounts)
|
||||
setSelectedItemTemplateId('')
|
||||
setSelectedAccountTemplateId('')
|
||||
} else if (urlUserId && !presetCustomer) {
|
||||
const listed = await listCustomers({ page: 1, pageSize: 100 }, controller.signal)
|
||||
const match = listed.items.find((c) => c.id === urlUserId)
|
||||
@@ -178,9 +187,10 @@ export function IssueInvoicePage() {
|
||||
setNotes('')
|
||||
setDraftItems([emptyDraftItem()])
|
||||
setKeyPoints([])
|
||||
setPaymentMethods([])
|
||||
setPaymentMethods(defaultDraftPaymentMethods())
|
||||
setAccounts([])
|
||||
setSelectedItemTemplateId('')
|
||||
setSelectedAccountTemplateId('')
|
||||
setFormError('')
|
||||
}
|
||||
|
||||
@@ -202,6 +212,7 @@ export function IssueInvoicePage() {
|
||||
setPaymentMethods(drafts.paymentMethods)
|
||||
setAccounts(drafts.accounts)
|
||||
setSelectedItemTemplateId('')
|
||||
setSelectedAccountTemplateId('')
|
||||
setFormError('')
|
||||
}
|
||||
|
||||
@@ -360,12 +371,15 @@ export function IssueInvoicePage() {
|
||||
|
||||
<InvoiceDraftFields
|
||||
itemTemplates={itemTemplates}
|
||||
accountTemplates={accountTemplates}
|
||||
items={draftItems}
|
||||
keyPoints={keyPoints}
|
||||
paymentMethods={paymentMethods}
|
||||
accounts={accounts}
|
||||
selectedItemTemplateId={selectedItemTemplateId}
|
||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||
selectedAccountTemplateId={selectedAccountTemplateId}
|
||||
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
|
||||
onItemsChange={setDraftItems}
|
||||
onKeyPointsChange={setKeyPoints}
|
||||
onPaymentMethodsChange={setPaymentMethods}
|
||||
|
||||
Reference in New Issue
Block a user