Add bank account templates and default invoice payment text.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-21 23:31:58 +03:30
co-authored by Cursor
parent 8465a29ce1
commit ff419603bf
19 changed files with 1316 additions and 107 deletions
@@ -135,6 +135,15 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap;
}
.blockHeaderActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
} }
.blockTitle { .blockTitle {
@@ -1,10 +1,11 @@
import { Pencil, Plus, X } from 'lucide-react' import { Pencil, Plus, X } from 'lucide-react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import type { InvoiceItemTemplate } from '../types/invoice' import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
import { formatIrtInput } from '../utils/irtPrice' import { formatIrtInput } from '../utils/irtPrice'
import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft' import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
import { import {
draftAccountFromAccountTemplate,
draftItemFromItemTemplate, draftItemFromItemTemplate,
emptyDraftAccount, emptyDraftAccount,
emptyDraftItem, emptyDraftItem,
@@ -28,34 +29,42 @@ function focusPaymentMethodInput(index: number) {
type Props = { type Props = {
itemTemplates: InvoiceItemTemplate[] itemTemplates: InvoiceItemTemplate[]
accountTemplates: InvoiceAccountTemplate[]
items: DraftLineItem[] items: DraftLineItem[]
keyPoints: DraftKeyPoint[] keyPoints: DraftKeyPoint[]
paymentMethods: DraftPaymentMethod[] paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[] accounts: DraftAccount[]
selectedItemTemplateId: string selectedItemTemplateId: string
onSelectedItemTemplateId: (id: string) => void onSelectedItemTemplateId: (id: string) => void
selectedAccountTemplateId: string
onSelectedAccountTemplateId: (id: string) => void
onItemsChange: (items: DraftLineItem[]) => void onItemsChange: (items: DraftLineItem[]) => void
onKeyPointsChange: (points: DraftKeyPoint[]) => void onKeyPointsChange: (points: DraftKeyPoint[]) => void
onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
onAccountsChange: (accounts: DraftAccount[]) => void onAccountsChange: (accounts: DraftAccount[]) => void
showItemTemplatePicker?: boolean showItemTemplatePicker?: boolean
showAccountTemplatePicker?: boolean
/** Rendered after line items (before key points / accounts), e.g. totals row. */ /** Rendered after line items (before key points / accounts), e.g. totals row. */
afterItems?: ReactNode afterItems?: ReactNode
} }
export function InvoiceDraftFields({ export function InvoiceDraftFields({
itemTemplates, itemTemplates,
accountTemplates,
items, items,
keyPoints, keyPoints,
paymentMethods, paymentMethods,
accounts, accounts,
selectedItemTemplateId, selectedItemTemplateId,
onSelectedItemTemplateId, onSelectedItemTemplateId,
selectedAccountTemplateId,
onSelectedAccountTemplateId,
onItemsChange, onItemsChange,
onKeyPointsChange, onKeyPointsChange,
onPaymentMethodsChange, onPaymentMethodsChange,
onAccountsChange, onAccountsChange,
showItemTemplatePicker = true, showItemTemplatePicker = true,
showAccountTemplatePicker = true,
afterItems, afterItems,
}: Props) { }: Props) {
const t = useT() const t = useT()
@@ -81,6 +90,13 @@ export function InvoiceDraftFields({
onSelectedItemTemplateId('') onSelectedItemTemplateId('')
} }
function addFromAccountTemplate() {
const template = accountTemplates.find((entry) => entry.id === selectedAccountTemplateId)
if (!template) return
onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)])
onSelectedAccountTemplateId('')
}
return ( return (
<> <>
<div className={styles.itemsStack}> <div className={styles.itemsStack}>
@@ -323,14 +339,44 @@ export function InvoiceDraftFields({
<div className={styles.blockSection}> <div className={styles.blockSection}>
<div className={styles.blockHeader}> <div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4> <h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
<button <div className={styles.blockHeaderActions}>
type="button" {showAccountTemplatePicker ? (
className={`${formStyles.btn} ${formStyles.btnGhost}`} <div className={styles.itemActionsLeft}>
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])} <select
> id="account-template-pick"
<Plus size={16} /> className={styles.compactSelect}
{t('invoiceDraft.addAccount')} value={selectedAccountTemplateId}
</button> onChange={(e) => onSelectedAccountTemplateId(e.target.value)}
aria-label={t('invoiceDraft.addFromAccountTemplateAria')}
>
<option value="">{t('invoiceDraft.addFromAccountTemplate')}</option>
{accountTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.bankName}
{template.accountHolderName ? ` — ${template.accountHolderName}` : ''}
</option>
))}
</select>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
onClick={addFromAccountTemplate}
disabled={!selectedAccountTemplateId}
>
<Plus size={14} />
{t('invoiceDraft.add')}
</button>
</div>
) : null}
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
>
<Plus size={16} />
{t('invoiceDraft.addAccount')}
</button>
</div>
</div> </div>
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p> <p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p>
+62 -2
View File
@@ -2417,6 +2417,8 @@ const en = {
'invoiceDraft.fieldDescriptionPlaceholder': 'Optional details', 'invoiceDraft.fieldDescriptionPlaceholder': 'Optional details',
'invoiceDraft.addFromTemplate': 'Add from template…', 'invoiceDraft.addFromTemplate': 'Add from template…',
'invoiceDraft.addFromTemplateAria': 'Add from item template', 'invoiceDraft.addFromTemplateAria': 'Add from item template',
'invoiceDraft.addFromAccountTemplate': 'Add from account template…',
'invoiceDraft.addFromAccountTemplateAria': 'Add from bank account template',
'invoiceDraft.add': 'Add', 'invoiceDraft.add': 'Add',
'invoiceDraft.addCustomItem': 'Add custom item', 'invoiceDraft.addCustomItem': 'Add custom item',
'invoiceDraft.keyPoints': 'Key points', 'invoiceDraft.keyPoints': 'Key points',
@@ -2444,7 +2446,7 @@ const en = {
'invoiceDraft.error.itemDiscountInvalid': 'Discounted price is invalid for "{title}".', 'invoiceDraft.error.itemDiscountInvalid': 'Discounted price is invalid for "{title}".',
'invoiceDraft.error.itemDiscountExceeds': 'Discounted price cannot exceed price for "{title}".', 'invoiceDraft.error.itemDiscountExceeds': 'Discounted price cannot exceed price for "{title}".',
'invoiceTemplates.subtitle': 'Manage reusable invoice templates and predefined line items.', 'invoiceTemplates.subtitle': 'Manage reusable invoice templates, line items, and bank accounts.',
'invoiceTemplates.section.templates.title': 'Invoice templates', 'invoiceTemplates.section.templates.title': 'Invoice templates',
'invoiceTemplates.section.templates.desc': 'invoiceTemplates.section.templates.desc':
'Full blueprints for faster issuing — pick one and edit, or start blank.', 'Full blueprints for faster issuing — pick one and edit, or start blank.',
@@ -2471,29 +2473,57 @@ const en = {
'invoiceTemplates.col.price': 'Price', 'invoiceTemplates.col.price': 'Price',
'invoiceTemplates.col.discounted': 'Discounted', 'invoiceTemplates.col.discounted': 'Discounted',
'invoiceTemplates.emptyItems': 'You have not added any predefined items yet.', 'invoiceTemplates.emptyItems': 'You have not added any predefined items yet.',
'invoiceTemplates.section.accounts.title': 'Bank account templates',
'invoiceTemplates.section.accounts.desc':
'Reusable bank accounts you can drop into invoice templates or when issuing an invoice.',
'invoiceTemplates.addAccount': 'Add account',
'invoiceTemplates.countAccountsOne': '{count} account',
'invoiceTemplates.countAccountsMany': '{count} accounts',
'invoiceTemplates.col.bankName': 'Bank',
'invoiceTemplates.col.accountHolder': 'Account holder',
'invoiceTemplates.col.cardNumber': 'Card number',
'invoiceTemplates.col.iban': 'IBAN',
'invoiceTemplates.emptyAccounts': 'You have not added any bank account templates yet.',
'invoiceTemplates.editTemplate': 'Edit', 'invoiceTemplates.editTemplate': 'Edit',
'invoiceTemplates.removeTemplate': 'Remove', 'invoiceTemplates.removeTemplate': 'Remove',
'invoiceTemplates.editItem': 'Edit', 'invoiceTemplates.editItem': 'Edit',
'invoiceTemplates.removeItem': 'Remove', 'invoiceTemplates.removeItem': 'Remove',
'invoiceTemplates.editAccount': 'Edit',
'invoiceTemplates.removeAccount': 'Remove',
'invoiceTemplates.deleteTemplateTitle': 'Remove invoice template', 'invoiceTemplates.deleteTemplateTitle': 'Remove invoice template',
'invoiceTemplates.deleteTemplateMessage': 'Remove "{name}" from invoice templates?', 'invoiceTemplates.deleteTemplateMessage': 'Remove "{name}" from invoice templates?',
'invoiceTemplates.deleteItemTitle': 'Remove item template', 'invoiceTemplates.deleteItemTitle': 'Remove item template',
'invoiceTemplates.deleteItemMessage': 'Remove "{title}" from predefined line items?', 'invoiceTemplates.deleteItemMessage': 'Remove "{title}" from predefined line items?',
'invoiceTemplates.deleteAccountTitle': 'Remove bank account template',
'invoiceTemplates.deleteAccountMessage': 'Remove "{name}" from bank account templates?',
'invoiceTemplates.itemModalAddTitle': 'Add item template', 'invoiceTemplates.itemModalAddTitle': 'Add item template',
'invoiceTemplates.itemModalEditTitle': 'Edit item template', 'invoiceTemplates.itemModalEditTitle': 'Edit item template',
'invoiceTemplates.accountModalAddTitle': 'Add bank account template',
'invoiceTemplates.accountModalEditTitle': 'Edit bank account template',
'invoiceTemplates.itemUpdated': 'Item template updated.', 'invoiceTemplates.itemUpdated': 'Item template updated.',
'invoiceTemplates.itemCreated': 'Item template created.', 'invoiceTemplates.itemCreated': 'Item template created.',
'invoiceTemplates.itemRemoved': 'Item template removed.', 'invoiceTemplates.itemRemoved': 'Item template removed.',
'invoiceTemplates.accountUpdated': 'Bank account template updated.',
'invoiceTemplates.accountCreated': 'Bank account template created.',
'invoiceTemplates.accountRemoved': 'Bank account template removed.',
'invoiceTemplates.templateRemoved': 'Invoice template removed.', 'invoiceTemplates.templateRemoved': 'Invoice template removed.',
'invoiceTemplates.loadError': 'Unable to load invoice settings.', 'invoiceTemplates.loadError': 'Unable to load invoice settings.',
'invoiceTemplates.itemSaveError': 'Unable to save item template.', 'invoiceTemplates.itemSaveError': 'Unable to save item template.',
'invoiceTemplates.itemRemoveError': 'Unable to remove item.', 'invoiceTemplates.itemRemoveError': 'Unable to remove item.',
'invoiceTemplates.accountSaveError': 'Unable to save bank account template.',
'invoiceTemplates.accountRemoveError': 'Unable to remove bank account.',
'invoiceTemplates.accountBankRequired': 'Bank name is required.',
'invoiceTemplates.templateRemoveError': 'Unable to remove template.', 'invoiceTemplates.templateRemoveError': 'Unable to remove template.',
'invoiceTemplates.saveItem': 'Save changes', 'invoiceTemplates.saveItem': 'Save changes',
'invoiceTemplates.savingItem': 'Saving…', 'invoiceTemplates.savingItem': 'Saving…',
'invoiceTemplates.createItem': 'Create item', 'invoiceTemplates.createItem': 'Create item',
'invoiceTemplates.saveAccount': 'Save changes',
'invoiceTemplates.savingAccount': 'Saving…',
'invoiceTemplates.createAccount': 'Create account',
'invoiceTemplates.cancel': 'Cancel', 'invoiceTemplates.cancel': 'Cancel',
'invoiceTemplates.hint': 'Tip: issue an invoice from the Invoices page using a template or from scratch.', 'invoiceTemplates.hint': 'Tip: issue an invoice from the Invoices page using a template or from scratch.',
'invoiceTemplates.expandSection': 'Show list',
'invoiceTemplates.collapseSection': 'Hide list',
'invoiceTemplateEditor.addTitle': 'Add invoice template', 'invoiceTemplateEditor.addTitle': 'Add invoice template',
'invoiceTemplateEditor.editTitle': 'Edit invoice template', 'invoiceTemplateEditor.editTitle': 'Edit invoice template',
@@ -5043,6 +5073,8 @@ const fa: Record<MessageKey, string> = {
'invoiceDraft.fieldDescriptionPlaceholder': 'جزئیات اختیاری', 'invoiceDraft.fieldDescriptionPlaceholder': 'جزئیات اختیاری',
'invoiceDraft.addFromTemplate': 'افزودن از قالب...', 'invoiceDraft.addFromTemplate': 'افزودن از قالب...',
'invoiceDraft.addFromTemplateAria': 'افزودن از قالب آیتم', 'invoiceDraft.addFromTemplateAria': 'افزودن از قالب آیتم',
'invoiceDraft.addFromAccountTemplate': 'افزودن از قالب حساب...',
'invoiceDraft.addFromAccountTemplateAria': 'افزودن از قالب حساب بانکی',
'invoiceDraft.add': 'افزودن', 'invoiceDraft.add': 'افزودن',
'invoiceDraft.addCustomItem': 'افزودن آیتم سفارشی', 'invoiceDraft.addCustomItem': 'افزودن آیتم سفارشی',
'invoiceDraft.keyPoints': 'نکات کلیدی', 'invoiceDraft.keyPoints': 'نکات کلیدی',
@@ -5070,7 +5102,7 @@ const fa: Record<MessageKey, string> = {
'invoiceDraft.error.itemDiscountInvalid': 'قیمت با تخفیف برای «{title}» نامعتبر است.', 'invoiceDraft.error.itemDiscountInvalid': 'قیمت با تخفیف برای «{title}» نامعتبر است.',
'invoiceDraft.error.itemDiscountExceeds': 'قیمت با تخفیف نمی‌تواند از قیمت اصلی «{title}» بیشتر باشد.', 'invoiceDraft.error.itemDiscountExceeds': 'قیمت با تخفیف نمی‌تواند از قیمت اصلی «{title}» بیشتر باشد.',
'invoiceTemplates.subtitle': 'قالب‌های فاکتور و آیتم‌های از پیش تعریف‌شده را مدیریت کنید.', 'invoiceTemplates.subtitle': 'قالب‌های فاکتور، آیتم‌ها و حساب‌های بانکی را مدیریت کنید.',
'invoiceTemplates.section.templates.title': 'قالب‌های فاکتور', 'invoiceTemplates.section.templates.title': 'قالب‌های فاکتور',
'invoiceTemplates.section.templates.desc': 'invoiceTemplates.section.templates.desc':
'الگوهای کامل برای صدور سریع‌تر فاکتور — یکی را انتخاب کنید یا از خالی شروع کنید.', 'الگوهای کامل برای صدور سریع‌تر فاکتور — یکی را انتخاب کنید یا از خالی شروع کنید.',
@@ -5097,29 +5129,57 @@ const fa: Record<MessageKey, string> = {
'invoiceTemplates.col.price': 'قیمت', 'invoiceTemplates.col.price': 'قیمت',
'invoiceTemplates.col.discounted': 'با تخفیف', 'invoiceTemplates.col.discounted': 'با تخفیف',
'invoiceTemplates.emptyItems': 'هنوز آیتم از پیش تعریف‌شده‌ای ثبت نکرده‌اید.', 'invoiceTemplates.emptyItems': 'هنوز آیتم از پیش تعریف‌شده‌ای ثبت نکرده‌اید.',
'invoiceTemplates.section.accounts.title': 'قالب‌های حساب بانکی',
'invoiceTemplates.section.accounts.desc':
'حساب‌های بانکی قابل استفاده مجدد که می‌توانید در قالب‌های فاکتور یا هنگام صدور فاکتور استفاده کنید.',
'invoiceTemplates.addAccount': 'افزودن حساب',
'invoiceTemplates.countAccountsOne': '{count} حساب',
'invoiceTemplates.countAccountsMany': '{count} حساب',
'invoiceTemplates.col.bankName': 'بانک',
'invoiceTemplates.col.accountHolder': 'صاحب حساب',
'invoiceTemplates.col.cardNumber': 'شماره کارت',
'invoiceTemplates.col.iban': 'شبا',
'invoiceTemplates.emptyAccounts': 'هنوز قالب حساب بانکی ثبت نکرده‌اید.',
'invoiceTemplates.editTemplate': 'ویرایش', 'invoiceTemplates.editTemplate': 'ویرایش',
'invoiceTemplates.removeTemplate': 'حذف', 'invoiceTemplates.removeTemplate': 'حذف',
'invoiceTemplates.editItem': 'ویرایش', 'invoiceTemplates.editItem': 'ویرایش',
'invoiceTemplates.removeItem': 'حذف', 'invoiceTemplates.removeItem': 'حذف',
'invoiceTemplates.editAccount': 'ویرایش',
'invoiceTemplates.removeAccount': 'حذف',
'invoiceTemplates.deleteTemplateTitle': 'حذف قالب فاکتور', 'invoiceTemplates.deleteTemplateTitle': 'حذف قالب فاکتور',
'invoiceTemplates.deleteTemplateMessage': '«{name}» از قالب‌های فاکتور حذف شود؟', 'invoiceTemplates.deleteTemplateMessage': '«{name}» از قالب‌های فاکتور حذف شود؟',
'invoiceTemplates.deleteItemTitle': 'حذف قالب آیتم', 'invoiceTemplates.deleteItemTitle': 'حذف قالب آیتم',
'invoiceTemplates.deleteItemMessage': '«{title}» از آیتم‌های از پیش تعریف‌شده حذف شود؟', 'invoiceTemplates.deleteItemMessage': '«{title}» از آیتم‌های از پیش تعریف‌شده حذف شود؟',
'invoiceTemplates.deleteAccountTitle': 'حذف قالب حساب بانکی',
'invoiceTemplates.deleteAccountMessage': '«{name}» از قالب‌های حساب بانکی حذف شود؟',
'invoiceTemplates.itemModalAddTitle': 'افزودن قالب آیتم', 'invoiceTemplates.itemModalAddTitle': 'افزودن قالب آیتم',
'invoiceTemplates.itemModalEditTitle': 'ویرایش قالب آیتم', 'invoiceTemplates.itemModalEditTitle': 'ویرایش قالب آیتم',
'invoiceTemplates.accountModalAddTitle': 'افزودن قالب حساب بانکی',
'invoiceTemplates.accountModalEditTitle': 'ویرایش قالب حساب بانکی',
'invoiceTemplates.itemUpdated': 'قالب آیتم به‌روزرسانی شد.', 'invoiceTemplates.itemUpdated': 'قالب آیتم به‌روزرسانی شد.',
'invoiceTemplates.itemCreated': 'قالب آیتم ایجاد شد.', 'invoiceTemplates.itemCreated': 'قالب آیتم ایجاد شد.',
'invoiceTemplates.itemRemoved': 'قالب آیتم حذف شد.', 'invoiceTemplates.itemRemoved': 'قالب آیتم حذف شد.',
'invoiceTemplates.accountUpdated': 'قالب حساب بانکی به‌روزرسانی شد.',
'invoiceTemplates.accountCreated': 'قالب حساب بانکی ایجاد شد.',
'invoiceTemplates.accountRemoved': 'قالب حساب بانکی حذف شد.',
'invoiceTemplates.templateRemoved': 'قالب فاکتور حذف شد.', 'invoiceTemplates.templateRemoved': 'قالب فاکتور حذف شد.',
'invoiceTemplates.loadError': 'بارگذاری تنظیمات فاکتور ممکن نشد.', 'invoiceTemplates.loadError': 'بارگذاری تنظیمات فاکتور ممکن نشد.',
'invoiceTemplates.itemSaveError': 'ذخیره قالب آیتم ممکن نشد.', 'invoiceTemplates.itemSaveError': 'ذخیره قالب آیتم ممکن نشد.',
'invoiceTemplates.itemRemoveError': 'حذف آیتم ممکن نشد.', 'invoiceTemplates.itemRemoveError': 'حذف آیتم ممکن نشد.',
'invoiceTemplates.accountSaveError': 'ذخیره قالب حساب بانکی ممکن نشد.',
'invoiceTemplates.accountRemoveError': 'حذف حساب بانکی ممکن نشد.',
'invoiceTemplates.accountBankRequired': 'نام بانک الزامی است.',
'invoiceTemplates.templateRemoveError': 'حذف قالب ممکن نشد.', 'invoiceTemplates.templateRemoveError': 'حذف قالب ممکن نشد.',
'invoiceTemplates.saveItem': 'ذخیره تغییرات', 'invoiceTemplates.saveItem': 'ذخیره تغییرات',
'invoiceTemplates.savingItem': 'در حال ذخیره...', 'invoiceTemplates.savingItem': 'در حال ذخیره...',
'invoiceTemplates.createItem': 'ایجاد آیتم', 'invoiceTemplates.createItem': 'ایجاد آیتم',
'invoiceTemplates.saveAccount': 'ذخیره تغییرات',
'invoiceTemplates.savingAccount': 'در حال ذخیره...',
'invoiceTemplates.createAccount': 'ایجاد حساب',
'invoiceTemplates.cancel': 'انصراف', 'invoiceTemplates.cancel': 'انصراف',
'invoiceTemplates.hint': 'نکته: از صفحه فاکتورها با استفاده از یک قالب یا از ابتدا فاکتور صادر کنید.', 'invoiceTemplates.hint': 'نکته: از صفحه فاکتورها با استفاده از یک قالب یا از ابتدا فاکتور صادر کنید.',
'invoiceTemplates.expandSection': 'نمایش فهرست',
'invoiceTemplates.collapseSection': 'بستن فهرست',
'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور', 'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور',
'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور', 'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور',
@@ -9,16 +9,18 @@ import { ApiError, isAbortError } from '../lib/api'
import { import {
createInvoiceTemplate, createInvoiceTemplate,
getInvoiceTemplate, getInvoiceTemplate,
listInvoiceAccountTemplates,
listInvoiceItemTemplates, listInvoiceItemTemplates,
updateInvoiceTemplate, updateInvoiceTemplate,
} from '../services/invoiceService' } from '../services/invoiceService'
import type { InvoiceItemTemplate } from '../types/invoice' import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
import { import {
buildAccountsPayload, buildAccountsPayload,
buildKeyPointsPayload, buildKeyPointsPayload,
buildPaymentMethodsPayload, buildPaymentMethodsPayload,
buildTemplateItemsPayload, buildTemplateItemsPayload,
draftsFromInvoiceTemplate, draftsFromInvoiceTemplate,
defaultDraftPaymentMethods,
emptyDraftItem, emptyDraftItem,
type DraftAccount, type DraftAccount,
type DraftKeyPoint, type DraftKeyPoint,
@@ -37,15 +39,19 @@ export function InvoiceTemplateEditorPage() {
const t = useT() const t = useT()
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([]) const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [loading, setLoading] = useState(isEdit) const [loading, setLoading] = useState(isEdit)
const [error, setError] = useState('') const [error, setError] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [topText, setTopText] = useState('') const [topText, setTopText] = useState('')
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()]) const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([]) const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([]) const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
isEdit ? [] : defaultDraftPaymentMethods(),
)
const [accounts, setAccounts] = useState<DraftAccount[]>([]) const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('') const [formError, setFormError] = useState('')
@@ -56,8 +62,12 @@ export function InvoiceTemplateEditorPage() {
setLoading(true) setLoading(true)
setError('') setError('')
try { 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)) setItemTemplates(itemsRes.items.filter((i) => i.isActive))
setAccountTemplates(accountsRes.items.filter((i) => i.isActive))
if (templateId) { if (templateId) {
const template = await getInvoiceTemplate(templateId, controller.signal) const template = await getInvoiceTemplate(templateId, controller.signal)
@@ -166,12 +176,15 @@ export function InvoiceTemplateEditorPage() {
<InvoiceDraftFields <InvoiceDraftFields
itemTemplates={itemTemplates} itemTemplates={itemTemplates}
accountTemplates={accountTemplates}
items={items} items={items}
keyPoints={keyPoints} keyPoints={keyPoints}
paymentMethods={paymentMethods} paymentMethods={paymentMethods}
accounts={accounts} accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId} selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId} onSelectedItemTemplateId={setSelectedItemTemplateId}
selectedAccountTemplateId={selectedAccountTemplateId}
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
onItemsChange={setItems} onItemsChange={setItems}
onKeyPointsChange={setKeyPoints} onKeyPointsChange={setKeyPoints}
onPaymentMethodsChange={setPaymentMethods} onPaymentMethodsChange={setPaymentMethods}
@@ -3,7 +3,35 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 16px; 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 { .sectionTitleRow {
@@ -11,9 +39,11 @@
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
color: var(--primary); color: var(--primary);
min-width: 0;
} }
.sectionTitle { .sectionTitle {
display: block;
margin: 0 0 2px; margin: 0 0 2px;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
@@ -21,6 +51,7 @@
} }
.sectionSubtitle { .sectionSubtitle {
display: block;
margin: 0; margin: 0;
font-size: 12.5px; font-size: 12.5px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -30,6 +61,26 @@
text-overflow: ellipsis; 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 { .topTextPreview {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -83,6 +134,26 @@
width: 8%; 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 { .nameCell {
min-width: 0; min-width: 0;
} }
@@ -105,4 +176,8 @@
.sectionHeader { .sectionHeader {
flex-direction: column; flex-direction: column;
} }
.sectionAction {
width: 100%;
}
} }
+378 -28
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom' 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 { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EmptyTableRow } from '../components/EmptyState' import { EmptyTableRow } from '../components/EmptyState'
@@ -12,20 +12,30 @@ import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
createInvoiceAccountTemplate,
createInvoiceItemTemplate, createInvoiceItemTemplate,
deleteInvoiceAccountTemplate,
deleteInvoiceItemTemplate, deleteInvoiceItemTemplate,
deleteInvoiceTemplate, deleteInvoiceTemplate,
listInvoiceAccountTemplates,
listInvoiceItemTemplates, listInvoiceItemTemplates,
listInvoiceTemplates, listInvoiceTemplates,
updateInvoiceAccountTemplate,
updateInvoiceItemTemplate, updateInvoiceItemTemplate,
} from '../services/invoiceService' } 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 { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css' import formStyles from '../components/InvoiceForm.module.css'
import tableStyles from './InvoicesPage.module.css' import tableStyles from './InvoicesPage.module.css'
import styles from './InvoiceTemplatesPage.module.css' import styles from './InvoiceTemplatesPage.module.css'
type SectionId = 'templates' | 'items' | 'accounts'
type ItemDraft = { type ItemDraft = {
title: string title: string
duration: string duration: string
@@ -35,6 +45,13 @@ type ItemDraft = {
discountedPrice: string discountedPrice: string
} }
type AccountDraft = {
bankName: string
accountHolderName: string
cardNumber: string
iban: string
}
const EMPTY_ITEM_DRAFT: ItemDraft = { const EMPTY_ITEM_DRAFT: ItemDraft = {
title: '', title: '',
duration: '', duration: '',
@@ -44,6 +61,13 @@ const EMPTY_ITEM_DRAFT: ItemDraft = {
discountedPrice: '', discountedPrice: '',
} }
const EMPTY_ACCOUNT_DRAFT: AccountDraft = {
bankName: '',
accountHolderName: '',
cardNumber: '',
iban: '',
}
function itemDraftFromTemplate(template: InvoiceItemTemplate): ItemDraft { function itemDraftFromTemplate(template: InvoiceItemTemplate): ItemDraft {
return { return {
title: template.title, 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() { export function InvoiceTemplatesPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
const t = useT() const t = useT()
const [openSection, setOpenSection] = useState<SectionId>('templates')
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([]) const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([]) const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -76,6 +166,17 @@ export function InvoiceTemplatesPage() {
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null) const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | 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) { function itemDraftToPayload(draft: ItemDraft) {
const price = parseIrtInput(draft.price) const price = parseIrtInput(draft.price)
if (!draft.title.trim()) throw new Error(t('invoiceDraft.error.itemTitle')) 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) { async function reload(signal?: AbortSignal) {
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const [items, templates] = await Promise.all([ const [items, accounts, templates] = await Promise.all([
listInvoiceItemTemplates(signal), listInvoiceItemTemplates(signal),
listInvoiceAccountTemplates(signal),
listInvoiceTemplates(signal), listInvoiceTemplates(signal),
]) ])
setItemTemplates(items.items) setItemTemplates(items.items)
setAccountTemplates(accounts.items)
setInvoiceTemplates(templates.items) setInvoiceTemplates(templates.items)
} catch (err) { } catch (err) {
if (isAbortError(err)) return 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 ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<Breadcrumbs <Breadcrumbs
@@ -207,15 +378,14 @@ export function InvoiceTemplatesPage() {
{error ? <p className={formStyles.alertError}>{error}</p> : null} {error ? <p className={formStyles.alertError}>{error}</p> : null}
<section className={formStyles.section}> <CollapsibleSection
<div className={styles.sectionHeader}> id="templates"
<div className={styles.sectionTitleRow}> open={openSection === 'templates'}
<FileStack size={18} /> onToggle={() => toggleSection('templates')}
<div> icon={<FileStack size={18} />}
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.templates.title')}</h3> title={t('invoiceTemplates.section.templates.title')}
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.templates.desc')}</p> subtitle={t('invoiceTemplates.section.templates.desc')}
</div> action={
</div>
<button <button
type="button" type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`} className={`${formStyles.btn} ${formStyles.btnPrimary}`}
@@ -224,8 +394,8 @@ export function InvoiceTemplatesPage() {
<Plus size={16} /> <Plus size={16} />
{t('invoiceTemplates.addTemplate')} {t('invoiceTemplates.addTemplate')}
</button> </button>
</div> }
>
<div className={tableStyles.tableWrap}> <div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<div className={tableStyles.meta}> <div className={tableStyles.meta}>
@@ -310,17 +480,16 @@ export function InvoiceTemplatesPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </CollapsibleSection>
<section className={`${formStyles.section} ${formStyles.sectionSpaced}`}> <CollapsibleSection
<div className={styles.sectionHeader}> id="items"
<div className={styles.sectionTitleRow}> open={openSection === 'items'}
<FileText size={18} /> onToggle={() => toggleSection('items')}
<div> icon={<FileText size={18} />}
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.items.title')}</h3> title={t('invoiceTemplates.section.items.title')}
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.items.desc')}</p> subtitle={t('invoiceTemplates.section.items.desc')}
</div> action={
</div>
<button <button
type="button" type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`} className={`${formStyles.btn} ${formStyles.btnPrimary}`}
@@ -329,8 +498,8 @@ export function InvoiceTemplatesPage() {
<Plus size={16} /> <Plus size={16} />
{t('invoiceTemplates.addItem')} {t('invoiceTemplates.addItem')}
</button> </button>
</div> }
>
<div className={tableStyles.tableWrap}> <div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<div className={tableStyles.meta}> <div className={tableStyles.meta}>
@@ -417,7 +586,103 @@ export function InvoiceTemplatesPage() {
</tbody> </tbody>
</table> </table>
</div> </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> <p className={styles.hint}>{t('invoiceTemplates.hint')}</p>
@@ -513,6 +778,79 @@ export function InvoiceTemplatesPage() {
</div> </div>
</Modal> </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 <ConfirmDeleteModal
open={!!removeTplTarget} open={!!removeTplTarget}
title={t('invoiceTemplates.deleteTemplateTitle')} title={t('invoiceTemplates.deleteTemplateTitle')}
@@ -536,6 +874,18 @@ export function InvoiceTemplatesPage() {
onCancel={() => setRemoveItemTarget(null)} onCancel={() => setRemoveItemTarget(null)}
onConfirm={() => void handleRemoveItem()} 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> </main>
) )
} }
+18 -4
View File
@@ -10,12 +10,13 @@ import { ApiError, isAbortError } from '../lib/api'
import { import {
createInvoice, createInvoice,
getInvoice, getInvoice,
listInvoiceAccountTemplates,
listInvoiceItemTemplates, listInvoiceItemTemplates,
listInvoiceTemplates, listInvoiceTemplates,
updateInvoice, updateInvoice,
} from '../services/invoiceService' } from '../services/invoiceService'
import { listCustomers, type BusinessCustomer } from '../services/customerService' import { listCustomers, type BusinessCustomer } from '../services/customerService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice' import type { InvoiceAccountTemplate, InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import { import {
buildAccountsPayload, buildAccountsPayload,
buildKeyPointsPayload, buildKeyPointsPayload,
@@ -23,6 +24,7 @@ import {
buildPaymentMethodsPayload, buildPaymentMethodsPayload,
draftsFromInvoice, draftsFromInvoice,
draftsFromInvoiceTemplate, draftsFromInvoiceTemplate,
defaultDraftPaymentMethods,
emptyDraftItem, emptyDraftItem,
type DraftAccount, type DraftAccount,
type DraftKeyPoint, type DraftKeyPoint,
@@ -72,6 +74,7 @@ export function IssueInvoicePage() {
?.presetCustomer ?.presetCustomer
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([]) const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([]) const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -90,9 +93,12 @@ export function IssueInvoicePage() {
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()]) const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([]) const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([]) const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
isEdit ? [] : defaultDraftPaymentMethods(),
)
const [accounts, setAccounts] = useState<DraftAccount[]>([]) const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('') const [formError, setFormError] = useState('')
@@ -114,11 +120,13 @@ export function IssueInvoicePage() {
setError('') setError('')
setLocked(false) setLocked(false)
try { try {
const [items, templates] = await Promise.all([ const [items, accountsRes, templates] = await Promise.all([
listInvoiceItemTemplates(controller.signal), listInvoiceItemTemplates(controller.signal),
listInvoiceAccountTemplates(controller.signal),
listInvoiceTemplates(controller.signal), listInvoiceTemplates(controller.signal),
]) ])
setItemTemplates(items.items.filter((i) => i.isActive)) setItemTemplates(items.items.filter((i) => i.isActive))
setAccountTemplates(accountsRes.items.filter((i) => i.isActive))
setInvoiceTemplates(templates.items.filter((i) => i.isActive)) setInvoiceTemplates(templates.items.filter((i) => i.isActive))
if (invoiceId) { if (invoiceId) {
@@ -142,6 +150,7 @@ export function IssueInvoicePage() {
setPaymentMethods(drafts.paymentMethods) setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts) setAccounts(drafts.accounts)
setSelectedItemTemplateId('') setSelectedItemTemplateId('')
setSelectedAccountTemplateId('')
} else if (urlUserId && !presetCustomer) { } else if (urlUserId && !presetCustomer) {
const listed = await listCustomers({ page: 1, pageSize: 100 }, controller.signal) const listed = await listCustomers({ page: 1, pageSize: 100 }, controller.signal)
const match = listed.items.find((c) => c.id === urlUserId) const match = listed.items.find((c) => c.id === urlUserId)
@@ -178,9 +187,10 @@ export function IssueInvoicePage() {
setNotes('') setNotes('')
setDraftItems([emptyDraftItem()]) setDraftItems([emptyDraftItem()])
setKeyPoints([]) setKeyPoints([])
setPaymentMethods([]) setPaymentMethods(defaultDraftPaymentMethods())
setAccounts([]) setAccounts([])
setSelectedItemTemplateId('') setSelectedItemTemplateId('')
setSelectedAccountTemplateId('')
setFormError('') setFormError('')
} }
@@ -202,6 +212,7 @@ export function IssueInvoicePage() {
setPaymentMethods(drafts.paymentMethods) setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts) setAccounts(drafts.accounts)
setSelectedItemTemplateId('') setSelectedItemTemplateId('')
setSelectedAccountTemplateId('')
setFormError('') setFormError('')
} }
@@ -360,12 +371,15 @@ export function IssueInvoicePage() {
<InvoiceDraftFields <InvoiceDraftFields
itemTemplates={itemTemplates} itemTemplates={itemTemplates}
accountTemplates={accountTemplates}
items={draftItems} items={draftItems}
keyPoints={keyPoints} keyPoints={keyPoints}
paymentMethods={paymentMethods} paymentMethods={paymentMethods}
accounts={accounts} accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId} selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId} onSelectedItemTemplateId={setSelectedItemTemplateId}
selectedAccountTemplateId={selectedAccountTemplateId}
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
onItemsChange={setDraftItems} onItemsChange={setDraftItems}
onKeyPointsChange={setKeyPoints} onKeyPointsChange={setKeyPoints}
onPaymentMethodsChange={setPaymentMethods} onPaymentMethodsChange={setPaymentMethods}
@@ -1,16 +1,20 @@
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext' import { getActiveBusinessId } from '../lib/businessContext'
import type { import type {
CreateInvoiceAccountTemplatePayload,
CreateInvoiceItemTemplatePayload, CreateInvoiceItemTemplatePayload,
CreateInvoicePayload, CreateInvoicePayload,
CreateInvoiceTemplatePayload, CreateInvoiceTemplatePayload,
Invoice, Invoice,
InvoiceAccountTemplate,
InvoiceAccountTemplatesResponse,
InvoiceItemTemplate, InvoiceItemTemplate,
InvoiceItemTemplatesResponse, InvoiceItemTemplatesResponse,
InvoiceStatus, InvoiceStatus,
InvoiceTemplate, InvoiceTemplate,
InvoiceTemplatesListResponse, InvoiceTemplatesListResponse,
InvoicesListResponse, InvoicesListResponse,
UpdateInvoiceAccountTemplatePayload,
UpdateInvoiceItemTemplatePayload, UpdateInvoiceItemTemplatePayload,
UpdateInvoicePayload, UpdateInvoicePayload,
UpdateInvoiceTemplatePayload, UpdateInvoiceTemplatePayload,
@@ -60,6 +64,42 @@ export function deleteInvoiceItemTemplate(templateId: string) {
}) })
} }
export function listInvoiceAccountTemplates(signal?: AbortSignal) {
return apiRequest<InvoiceAccountTemplatesResponse>(businessPath('/invoice-account-templates'), {
auth: true,
signal,
})
}
export function createInvoiceAccountTemplate(payload: CreateInvoiceAccountTemplatePayload) {
return apiRequest<InvoiceAccountTemplate>(businessPath('/invoice-account-templates'), {
method: 'POST',
auth: true,
body: payload,
})
}
export function updateInvoiceAccountTemplate(
templateId: string,
payload: UpdateInvoiceAccountTemplatePayload,
) {
return apiRequest<InvoiceAccountTemplate>(
businessPath(`/invoice-account-templates/${templateId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export function deleteInvoiceAccountTemplate(templateId: string) {
return apiRequest<{ ok: boolean }>(businessPath(`/invoice-account-templates/${templateId}`), {
method: 'DELETE',
auth: true,
})
}
export function listInvoiceTemplates(signal?: AbortSignal) { export function listInvoiceTemplates(signal?: AbortSignal) {
return apiRequest<InvoiceTemplatesListResponse>(businessPath('/invoice-templates'), { return apiRequest<InvoiceTemplatesListResponse>(businessPath('/invoice-templates'), {
auth: true, auth: true,
+30
View File
@@ -16,6 +16,20 @@ export interface InvoiceItemTemplate {
updatedAt: string updatedAt: string
} }
export interface InvoiceAccountTemplate {
id: string
ownerScope: 'platform' | 'business'
businessId: string | null
bankName: string
accountHolderName: string | null
cardNumber: string | null
iban: string | null
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
}
export interface InvoiceItem { export interface InvoiceItem {
id: string id: string
invoiceId: string invoiceId: string
@@ -198,6 +212,10 @@ export interface InvoiceItemTemplatesResponse {
items: InvoiceItemTemplate[] items: InvoiceItemTemplate[]
} }
export interface InvoiceAccountTemplatesResponse {
items: InvoiceAccountTemplate[]
}
export interface InvoiceTemplatesListResponse { export interface InvoiceTemplatesListResponse {
items: InvoiceTemplate[] items: InvoiceTemplate[]
} }
@@ -223,3 +241,15 @@ export interface CreateInvoiceItemTemplatePayload {
export type UpdateInvoiceItemTemplatePayload = Partial<CreateInvoiceItemTemplatePayload> & { export type UpdateInvoiceItemTemplatePayload = Partial<CreateInvoiceItemTemplatePayload> & {
isActive?: boolean isActive?: boolean
} }
export interface CreateInvoiceAccountTemplatePayload {
bankName: string
accountHolderName?: string
cardNumber?: string
iban?: string
sortOrder?: number
}
export type UpdateInvoiceAccountTemplatePayload = Partial<CreateInvoiceAccountTemplatePayload> & {
isActive?: boolean
}
+21 -2
View File
@@ -2,6 +2,7 @@ import { formatIrtInput, parseIrtInput } from './irtPrice'
import type { BusinessMessageKey } from '../i18n/messages' import type { BusinessMessageKey } from '../i18n/messages'
import type { import type {
InvoiceAccountInput, InvoiceAccountInput,
InvoiceAccountTemplate,
InvoiceItemInput, InvoiceItemInput,
InvoiceItemTemplate, InvoiceItemTemplate,
InvoiceKeyPointInput, InvoiceKeyPointInput,
@@ -51,6 +52,10 @@ export type DraftAccount = {
iban: string iban: string
} }
/** Default free-text payment row shown on new invoice / template drafts. */
export const DEFAULT_PAYMENT_METHOD_TEXT =
'مبلغ ۱.۰۰۰.۰۰۰ تومان پیش پرداخت در تاریخ ۱۰-۱۰-۱۴۱۰ به صورت نقدی'
export function newKey() { export function newKey() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
} }
@@ -71,8 +76,12 @@ export function emptyDraftKeyPoint(): DraftKeyPoint {
return { key: newKey(), text: '' } return { key: newKey(), text: '' }
} }
export function emptyDraftPaymentMethod(): DraftPaymentMethod { export function emptyDraftPaymentMethod(text = ''): DraftPaymentMethod {
return { key: newKey(), text: '' } return { key: newKey(), text }
}
export function defaultDraftPaymentMethods(): DraftPaymentMethod[] {
return [emptyDraftPaymentMethod(DEFAULT_PAYMENT_METHOD_TEXT)]
} }
export function emptyDraftAccount(): DraftAccount { export function emptyDraftAccount(): DraftAccount {
@@ -95,6 +104,16 @@ export function draftItemFromItemTemplate(template: InvoiceItemTemplate): DraftL
} }
} }
export function draftAccountFromAccountTemplate(template: InvoiceAccountTemplate): DraftAccount {
return {
key: newKey(),
bankName: template.bankName,
accountHolderName: template.accountHolderName ?? '',
cardNumber: template.cardNumber ?? '',
iban: template.iban ?? '',
}
}
export function draftsFromInvoiceTemplate(template: InvoiceTemplate): { export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
name: string name: string
topText: string topText: string
@@ -1,8 +1,9 @@
import { Pencil, Plus, X } from 'lucide-react' import { Pencil, Plus, X } from 'lucide-react'
import type { InvoiceItemTemplate } from '../types/invoice' import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice' import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice'
import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft' import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
import { import {
draftAccountFromAccountTemplate,
draftItemFromItemTemplate, draftItemFromItemTemplate,
emptyDraftAccount, emptyDraftAccount,
emptyDraftItem, emptyDraftItem,
@@ -26,32 +27,40 @@ function focusPaymentMethodInput(index: number) {
type Props = { type Props = {
itemTemplates: InvoiceItemTemplate[] itemTemplates: InvoiceItemTemplate[]
accountTemplates: InvoiceAccountTemplate[]
items: DraftLineItem[] items: DraftLineItem[]
keyPoints: DraftKeyPoint[] keyPoints: DraftKeyPoint[]
paymentMethods: DraftPaymentMethod[] paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[] accounts: DraftAccount[]
selectedItemTemplateId: string selectedItemTemplateId: string
onSelectedItemTemplateId: (id: string) => void onSelectedItemTemplateId: (id: string) => void
selectedAccountTemplateId: string
onSelectedAccountTemplateId: (id: string) => void
onItemsChange: (items: DraftLineItem[]) => void onItemsChange: (items: DraftLineItem[]) => void
onKeyPointsChange: (points: DraftKeyPoint[]) => void onKeyPointsChange: (points: DraftKeyPoint[]) => void
onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
onAccountsChange: (accounts: DraftAccount[]) => void onAccountsChange: (accounts: DraftAccount[]) => void
showItemTemplatePicker?: boolean showItemTemplatePicker?: boolean
showAccountTemplatePicker?: boolean
} }
export function InvoiceDraftFields({ export function InvoiceDraftFields({
itemTemplates, itemTemplates,
accountTemplates,
items, items,
keyPoints, keyPoints,
paymentMethods, paymentMethods,
accounts, accounts,
selectedItemTemplateId, selectedItemTemplateId,
onSelectedItemTemplateId, onSelectedItemTemplateId,
selectedAccountTemplateId,
onSelectedAccountTemplateId,
onItemsChange, onItemsChange,
onKeyPointsChange, onKeyPointsChange,
onPaymentMethodsChange, onPaymentMethodsChange,
onAccountsChange, onAccountsChange,
showItemTemplatePicker = true, showItemTemplatePicker = true,
showAccountTemplatePicker = true,
}: Props) { }: Props) {
function updateItem(key: string, patch: Partial<DraftLineItem>) { function updateItem(key: string, patch: Partial<DraftLineItem>) {
onItemsChange(items.map((item) => (item.key === key ? { ...item, ...patch } : item))) onItemsChange(items.map((item) => (item.key === key ? { ...item, ...patch } : item)))
@@ -74,6 +83,13 @@ export function InvoiceDraftFields({
onSelectedItemTemplateId('') onSelectedItemTemplateId('')
} }
function addFromAccountTemplate() {
const template = accountTemplates.find((t) => t.id === selectedAccountTemplateId)
if (!template) return
onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)])
onSelectedAccountTemplateId('')
}
return ( return (
<> <>
<div className={styles.itemsStack}> <div className={styles.itemsStack}>
@@ -314,14 +330,44 @@ export function InvoiceDraftFields({
<div className={styles.blockSection}> <div className={styles.blockSection}>
<div className={styles.blockHeader}> <div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>Account numbers</h4> <h4 className={styles.blockTitle}>Account numbers</h4>
<button <div className={styles.blockHeaderActions}>
type="button" {showAccountTemplatePicker ? (
className={`${tableStyles.btn} ${tableStyles.btnGhost}`} <div className={styles.itemActionsLeft}>
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])} <select
> id="account-template-pick"
<Plus size={16} /> className={styles.compactSelect}
Add account value={selectedAccountTemplateId}
</button> onChange={(e) => onSelectedAccountTemplateId(e.target.value)}
aria-label="Add from account template"
>
<option value="">Add from account template…</option>
{accountTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.bankName}
{template.accountHolderName ? ` — ${template.accountHolderName}` : ''}
</option>
))}
</select>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost} ${styles.compactBtn}`}
onClick={addFromAccountTemplate}
disabled={!selectedAccountTemplateId}
>
<Plus size={14} />
Add
</button>
</div>
) : null}
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
>
<Plus size={16} />
Add account
</button>
</div>
</div> </div>
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<p className={styles.templateHint}>No bank accounts yet.</p> <p className={styles.templateHint}>No bank accounts yet.</p>
@@ -424,6 +424,15 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap;
}
.blockHeaderActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
} }
.blockTitle { .blockTitle {
@@ -8,15 +8,17 @@ import { ApiError, isAbortError } from '../lib/api'
import { import {
createInvoiceTemplate, createInvoiceTemplate,
getInvoiceTemplate, getInvoiceTemplate,
listInvoiceAccountTemplates,
listInvoiceItemTemplates, listInvoiceItemTemplates,
updateInvoiceTemplate, updateInvoiceTemplate,
} from '../services/invoiceService' } from '../services/invoiceService'
import type { InvoiceItemTemplate } from '../types/invoice' import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
import { import {
buildAccountsPayload, buildAccountsPayload,
buildKeyPointsPayload, buildKeyPointsPayload,
buildPaymentMethodsPayload, buildPaymentMethodsPayload,
buildTemplateItemsPayload, buildTemplateItemsPayload,
defaultDraftPaymentMethods,
draftsFromInvoiceTemplate, draftsFromInvoiceTemplate,
emptyDraftItem, emptyDraftItem,
type DraftAccount, type DraftAccount,
@@ -36,15 +38,19 @@ export function InvoiceTemplateEditorPage() {
const { showToast } = useToast() const { showToast } = useToast()
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([]) const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [loading, setLoading] = useState(isEdit) const [loading, setLoading] = useState(isEdit)
const [error, setError] = useState('') const [error, setError] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [topText, setTopText] = useState('') const [topText, setTopText] = useState('')
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()]) const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([]) const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([]) const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
defaultDraftPaymentMethods(),
)
const [accounts, setAccounts] = useState<DraftAccount[]>([]) const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('') const [formError, setFormError] = useState('')
@@ -55,8 +61,12 @@ export function InvoiceTemplateEditorPage() {
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const itemsRes = await listInvoiceItemTemplates(controller.signal) const [itemsRes, accountsRes] = await Promise.all([
listInvoiceItemTemplates(controller.signal),
listInvoiceAccountTemplates(controller.signal),
])
setItemTemplates(itemsRes.items.filter((t) => t.isActive)) setItemTemplates(itemsRes.items.filter((t) => t.isActive))
setAccountTemplates(accountsRes.items.filter((t) => t.isActive))
if (templateId) { if (templateId) {
const template = await getInvoiceTemplate(templateId, controller.signal) const template = await getInvoiceTemplate(templateId, controller.signal)
@@ -167,12 +177,15 @@ export function InvoiceTemplateEditorPage() {
<InvoiceDraftFields <InvoiceDraftFields
itemTemplates={itemTemplates} itemTemplates={itemTemplates}
accountTemplates={accountTemplates}
items={items} items={items}
keyPoints={keyPoints} keyPoints={keyPoints}
paymentMethods={paymentMethods} paymentMethods={paymentMethods}
accounts={accounts} accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId} selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId} onSelectedItemTemplateId={setSelectedItemTemplateId}
selectedAccountTemplateId={selectedAccountTemplateId}
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
onItemsChange={setItems} onItemsChange={setItems}
onKeyPointsChange={setKeyPoints} onKeyPointsChange={setKeyPoints}
onPaymentMethodsChange={setPaymentMethods} onPaymentMethodsChange={setPaymentMethods}
@@ -9,16 +9,18 @@ import { getBusiness, type BusinessDetail } from '../services/businessService'
import { import {
createBusinessInvoice, createBusinessInvoice,
getBusinessInvoice, getBusinessInvoice,
listInvoiceAccountTemplates,
listInvoiceItemTemplates, listInvoiceItemTemplates,
listInvoiceTemplates, listInvoiceTemplates,
updateBusinessInvoice, updateBusinessInvoice,
} from '../services/invoiceService' } from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice' import type { InvoiceAccountTemplate, InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import { import {
buildAccountsPayload, buildAccountsPayload,
buildKeyPointsPayload, buildKeyPointsPayload,
buildLineItemsPayload, buildLineItemsPayload,
buildPaymentMethodsPayload, buildPaymentMethodsPayload,
defaultDraftPaymentMethods,
draftsFromInvoice, draftsFromInvoice,
draftsFromInvoiceTemplate, draftsFromInvoiceTemplate,
emptyDraftItem, emptyDraftItem,
@@ -48,6 +50,7 @@ export function IssueInvoicePage() {
const [business, setBusiness] = useState<BusinessDetail | null>(null) const [business, setBusiness] = useState<BusinessDetail | null>(null)
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([]) const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([]) const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -60,9 +63,12 @@ export function IssueInvoicePage() {
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()]) const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([]) const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([]) const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
defaultDraftPaymentMethods(),
)
const [accounts, setAccounts] = useState<DraftAccount[]>([]) const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('') const [formError, setFormError] = useState('')
@@ -87,13 +93,15 @@ export function IssueInvoicePage() {
setError('') setError('')
setLocked(false) setLocked(false)
try { try {
const [biz, items, templates] = await Promise.all([ const [biz, items, accountsRes, templates] = await Promise.all([
getBusiness(businessId, controller.signal), getBusiness(businessId, controller.signal),
listInvoiceItemTemplates(controller.signal), listInvoiceItemTemplates(controller.signal),
listInvoiceAccountTemplates(controller.signal),
listInvoiceTemplates(controller.signal), listInvoiceTemplates(controller.signal),
]) ])
setBusiness(biz) setBusiness(biz)
setItemTemplates(items.items.filter((t) => t.isActive)) setItemTemplates(items.items.filter((t) => t.isActive))
setAccountTemplates(accountsRes.items.filter((t) => t.isActive))
setInvoiceTemplates(templates.items.filter((t) => t.isActive)) setInvoiceTemplates(templates.items.filter((t) => t.isActive))
if (invoiceId) { if (invoiceId) {
@@ -113,6 +121,7 @@ export function IssueInvoicePage() {
setPaymentMethods(drafts.paymentMethods) setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts) setAccounts(drafts.accounts)
setSelectedItemTemplateId('') setSelectedItemTemplateId('')
setSelectedAccountTemplateId('')
} }
} catch (err) { } catch (err) {
if (isAbortError(err)) return if (isAbortError(err)) return
@@ -134,9 +143,10 @@ export function IssueInvoicePage() {
setNotes('') setNotes('')
setDraftItems([emptyDraftItem()]) setDraftItems([emptyDraftItem()])
setKeyPoints([]) setKeyPoints([])
setPaymentMethods([]) setPaymentMethods(defaultDraftPaymentMethods())
setAccounts([]) setAccounts([])
setSelectedItemTemplateId('') setSelectedItemTemplateId('')
setSelectedAccountTemplateId('')
setFormError('') setFormError('')
} }
@@ -158,6 +168,7 @@ export function IssueInvoicePage() {
setPaymentMethods(drafts.paymentMethods) setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts) setAccounts(drafts.accounts)
setSelectedItemTemplateId('') setSelectedItemTemplateId('')
setSelectedAccountTemplateId('')
setFormError('') setFormError('')
} }
@@ -306,12 +317,15 @@ export function IssueInvoicePage() {
<InvoiceDraftFields <InvoiceDraftFields
itemTemplates={itemTemplates} itemTemplates={itemTemplates}
accountTemplates={accountTemplates}
items={draftItems} items={draftItems}
keyPoints={keyPoints} keyPoints={keyPoints}
paymentMethods={paymentMethods} paymentMethods={paymentMethods}
accounts={accounts} accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId} selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId} onSelectedItemTemplateId={setSelectedItemTemplateId}
selectedAccountTemplateId={selectedAccountTemplateId}
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
onItemsChange={setDraftItems} onItemsChange={setDraftItems}
onKeyPointsChange={setKeyPoints} onKeyPointsChange={setKeyPoints}
onPaymentMethodsChange={setPaymentMethods} onPaymentMethodsChange={setPaymentMethods}
@@ -17,22 +17,47 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 16px; 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 { .sectionTitleRow {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
}
.sectionTitleRow svg {
margin-top: 2px;
color: var(--primary); color: var(--primary);
flex-shrink: 0; min-width: 0;
} }
.sectionTitle { .sectionTitle {
display: block;
margin: 0 0 4px; margin: 0 0 4px;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
@@ -40,11 +65,32 @@
} }
.sectionSubtitle { .sectionSubtitle {
display: block;
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
} }
.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;
}
.itemTitle { .itemTitle {
font-weight: 600; font-weight: 600;
} }
+373 -34
View File
@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react' import {
ChevronDown,
FileStack,
FileText,
Landmark,
Pencil,
Plus,
Settings as SettingsIcon,
Trash2,
} from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EmptyTableRow } from '../components/EmptyState' import { EmptyTableRow } from '../components/EmptyState'
import { Modal } from '../components/Modal' import { Modal } from '../components/Modal'
@@ -8,19 +17,29 @@ import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
createInvoiceAccountTemplate,
createInvoiceItemTemplate, createInvoiceItemTemplate,
deleteInvoiceAccountTemplate,
deleteInvoiceItemTemplate, deleteInvoiceItemTemplate,
deleteInvoiceTemplate, deleteInvoiceTemplate,
listInvoiceAccountTemplates,
listInvoiceItemTemplates, listInvoiceItemTemplates,
listInvoiceTemplates, listInvoiceTemplates,
updateInvoiceAccountTemplate,
updateInvoiceItemTemplate, updateInvoiceItemTemplate,
} from '../services/invoiceService' } 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 { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css' import tableStyles from './BusinessesPage.module.css'
import styles from './SettingsPage.module.css' import styles from './SettingsPage.module.css'
type SectionId = 'templates' | 'items' | 'accounts'
type ItemDraft = { type ItemDraft = {
title: string title: string
duration: string duration: string
@@ -30,6 +49,13 @@ type ItemDraft = {
discountedPrice: string discountedPrice: string
} }
type AccountDraft = {
bankName: string
accountHolderName: string
cardNumber: string
iban: string
}
const EMPTY_ITEM_DRAFT: ItemDraft = { const EMPTY_ITEM_DRAFT: ItemDraft = {
title: '', title: '',
duration: '', duration: '',
@@ -39,6 +65,13 @@ const EMPTY_ITEM_DRAFT: ItemDraft = {
discountedPrice: '', discountedPrice: '',
} }
const EMPTY_ACCOUNT_DRAFT: AccountDraft = {
bankName: '',
accountHolderName: '',
cardNumber: '',
iban: '',
}
function itemDraftFromTemplate(t: InvoiceItemTemplate): ItemDraft { function itemDraftFromTemplate(t: InvoiceItemTemplate): ItemDraft {
return { return {
title: t.title, title: t.title,
@@ -76,10 +109,83 @@ function itemDraftToPayload(draft: ItemDraft) {
} }
} }
function accountDraftFromTemplate(t: InvoiceAccountTemplate): AccountDraft {
return {
bankName: t.bankName,
accountHolderName: t.accountHolderName ?? '',
cardNumber: t.cardNumber ?? '',
iban: t.iban ?? '',
}
}
function accountDraftToPayload(draft: AccountDraft) {
if (!draft.bankName.trim()) throw new Error('Bank name is required.')
return {
bankName: draft.bankName.trim(),
accountHolderName: draft.accountHolderName.trim() || undefined,
cardNumber: draft.cardNumber.trim() || undefined,
iban: draft.iban.trim() || undefined,
}
}
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
}) {
return (
<section className={`${styles.section} ${id !== 'templates' ? styles.sectionSpaced : ''}`}>
<div className={styles.sectionHeader}>
<button
type="button"
className={styles.sectionToggle}
onClick={onToggle}
aria-expanded={open}
aria-controls={`settings-invoice-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 ? 'Hide list' : 'Show list'}</span>
</button>
{open && action ? <div className={styles.sectionAction}>{action}</div> : null}
</div>
{open ? (
<div id={`settings-invoice-section-${id}`} className={styles.sectionBody}>
{children}
</div>
) : null}
</section>
)
}
export function SettingsPage() { export function SettingsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
const [openSection, setOpenSection] = useState<SectionId | null>('templates')
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([]) const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([]) const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -92,15 +198,30 @@ export function SettingsPage() {
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null) const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | 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((current) => (current === id ? null : id))
}
async function reload(signal?: AbortSignal) { async function reload(signal?: AbortSignal) {
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const [items, templates] = await Promise.all([ const [items, accounts, templates] = await Promise.all([
listInvoiceItemTemplates(signal), listInvoiceItemTemplates(signal),
listInvoiceAccountTemplates(signal),
listInvoiceTemplates(signal), listInvoiceTemplates(signal),
]) ])
setItemTemplates(items.items) setItemTemplates(items.items)
setAccountTemplates(accounts.items)
setInvoiceTemplates(templates.items) setInvoiceTemplates(templates.items)
} catch (err) { } catch (err) {
if (isAbortError(err)) return if (isAbortError(err)) return
@@ -181,6 +302,61 @@ export function SettingsPage() {
} }
} }
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 : 'Invalid form.')
return
}
setAccountSubmitting(true)
try {
if (editingAccount) {
await updateInvoiceAccountTemplate(editingAccount.id, payload)
showToast('Bank account template updated.', 'success')
} else {
await createInvoiceAccountTemplate(payload)
showToast('Bank account template created.', 'success')
}
setAccountEditorOpen(false)
await reload()
} catch (err) {
setAccountFormError(
err instanceof ApiError ? err.message : 'Unable to save bank account template.',
)
} finally {
setAccountSubmitting(false)
}
}
async function handleRemoveAccount() {
if (!removeAccountTarget) return
try {
await deleteInvoiceAccountTemplate(removeAccountTarget.id)
showToast('Bank account template removed.', 'success')
setRemoveAccountTarget(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : 'Unable to remove bank account.', 'error')
}
}
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
@@ -194,18 +370,14 @@ export function SettingsPage() {
{error ? <p className={styles.alertError}>{error}</p> : null} {error ? <p className={styles.alertError}>{error}</p> : null}
<section className={styles.section}> <CollapsibleSection
<div className={styles.sectionHeader}> id="templates"
<div className={styles.sectionTitleRow}> open={openSection === 'templates'}
<FileStack size={18} /> onToggle={() => toggleSection('templates')}
<div> icon={<FileStack size={18} />}
<h3 className={styles.sectionTitle}>Invoice templates</h3> title="Invoice templates"
<p className={styles.sectionSubtitle}> subtitle="Full blueprints (name, top text, items, key points, bank accounts) used when issuing invoices to businesses. Pick one and edit, or start blank."
Full blueprints (name, top text, items, key points, bank accounts) used when issuing action={
invoices to businesses. Pick one and edit, or start blank.
</p>
</div>
</div>
<button <button
type="button" type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`} className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
@@ -214,8 +386,8 @@ export function SettingsPage() {
<Plus size={16} /> <Plus size={16} />
Add template Add template
</button> </button>
</div> }
>
<div className={tableStyles.tableWrap}> <div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Templates</div> <div className={tableStyles.tableHeaderTitle}>Templates</div>
@@ -250,7 +422,10 @@ export function SettingsPage() {
<td className={tableStyles.td}> <td className={tableStyles.td}>
<div className={styles.itemTitle}>{template.name}</div> <div className={styles.itemTitle}>{template.name}</div>
{template.topText && !isEmptyRichText(template.topText) ? ( {template.topText && !isEmptyRichText(template.topText) ? (
<div className={`${tableStyles.subText} ${styles.topTextPreview}`} title={richTextToPlain(template.topText)}> <div
className={`${tableStyles.subText} ${styles.topTextPreview}`}
title={richTextToPlain(template.topText)}
>
{richTextToPlain(template.topText)} {richTextToPlain(template.topText)}
</div> </div>
) : null} ) : null}
@@ -285,19 +460,16 @@ export function SettingsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </CollapsibleSection>
<section className={`${styles.section} ${styles.sectionSpaced}`}> <CollapsibleSection
<div className={styles.sectionHeader}> id="items"
<div className={styles.sectionTitleRow}> open={openSection === 'items'}
<FileText size={18} /> onToggle={() => toggleSection('items')}
<div> icon={<FileText size={18} />}
<h3 className={styles.sectionTitle}>Invoice item templates</h3> title="Invoice item templates"
<p className={styles.sectionSubtitle}> subtitle="Reusable line items you can drop into invoice templates or when issuing an invoice."
Reusable line items you can drop into invoice templates or when issuing an invoice. action={
</p>
</div>
</div>
<button <button
type="button" type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`} className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
@@ -306,8 +478,8 @@ export function SettingsPage() {
<Plus size={16} /> <Plus size={16} />
Add item Add item
</button> </button>
</div> }
>
<div className={tableStyles.tableWrap}> <div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Predefined line items</div> <div className={tableStyles.tableHeaderTitle}>Predefined line items</div>
@@ -381,7 +553,91 @@ export function SettingsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </CollapsibleSection>
<CollapsibleSection
id="accounts"
open={openSection === 'accounts'}
onToggle={() => toggleSection('accounts')}
icon={<Landmark size={18} />}
title="Bank account templates"
subtitle="Reusable bank accounts you can drop into invoice templates or when issuing an invoice."
action={
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={openCreateAccount}
>
<Plus size={16} />
Add account
</button>
}
>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Bank accounts</div>
<div className={tableStyles.meta}>
{loading
? 'Loading…'
: `${accountTemplates.length} account${accountTemplates.length === 1 ? '' : 's'}`}
</div>
</div>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={tableStyles.th}>Bank</th>
<th className={tableStyles.th}>Account holder</th>
<th className={tableStyles.th}>Card number</th>
<th className={tableStyles.th}>IBAN</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{!loading && accountTemplates.length === 0 ? (
<EmptyTableRow
colSpan={5}
icon={Landmark}
message="No bank account templates yet. Add one to reuse when building invoices."
actionLabel="Add account"
onAction={openCreateAccount}
/>
) : null}
{accountTemplates.map((template) => (
<tr key={template.id}>
<td className={tableStyles.td}>
<div className={styles.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}>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => openEditAccount(template)}
title="Edit"
aria-label="Edit"
>
<Pencil size={16} />
</button>
<button
type="button"
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
onClick={() => setRemoveAccountTarget(template)}
title="Remove"
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
<p className={styles.hint}> <p className={styles.hint}>
Tip: open a business from <Link to="/businesses">Businesses</Link> to issue an invoice from a Tip: open a business from <Link to="/businesses">Businesses</Link> to issue an invoice from a
@@ -478,6 +734,77 @@ export function SettingsPage() {
</div> </div>
</Modal> </Modal>
<Modal
open={accountEditorOpen}
title={editingAccount ? 'Edit bank account template' : 'Add bank account template'}
onClose={() => !accountSubmitting && setAccountEditorOpen(false)}
wide
>
<div className={styles.formGrid}>
<div className={`${tableStyles.field} ${styles.span2}`}>
<label htmlFor="account-bank">Bank name</label>
<input
id="account-bank"
value={accountDraft.bankName}
onChange={(e) => setAccountDraft((d) => ({ ...d, bankName: e.target.value }))}
placeholder="e.g. Mellat"
/>
</div>
<div className={`${tableStyles.field} ${styles.span2}`}>
<label htmlFor="account-holder">Account holder</label>
<input
id="account-holder"
value={accountDraft.accountHolderName}
onChange={(e) =>
setAccountDraft((d) => ({ ...d, accountHolderName: e.target.value }))
}
placeholder="Optional"
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="account-card">Card number</label>
<input
id="account-card"
value={accountDraft.cardNumber}
onChange={(e) => setAccountDraft((d) => ({ ...d, cardNumber: e.target.value }))}
placeholder="Optional"
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="account-iban">IBAN</label>
<input
id="account-iban"
value={accountDraft.iban}
onChange={(e) => setAccountDraft((d) => ({ ...d, iban: e.target.value }))}
placeholder="Optional"
/>
</div>
</div>
{accountFormError ? <p className={styles.alertError}>{accountFormError}</p> : null}
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setAccountEditorOpen(false)}
disabled={accountSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void handleSaveAccount()}
disabled={accountSubmitting}
>
{accountSubmitting
? 'Saving…'
: editingAccount
? 'Save changes'
: 'Create account'}
</button>
</div>
</Modal>
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!removeTplTarget} open={!!removeTplTarget}
title="Remove invoice template" title="Remove invoice template"
@@ -501,6 +828,18 @@ export function SettingsPage() {
onCancel={() => setRemoveItemTarget(null)} onCancel={() => setRemoveItemTarget(null)}
onConfirm={() => void handleRemoveItem()} onConfirm={() => void handleRemoveItem()}
/> />
<ConfirmDeleteModal
open={!!removeAccountTarget}
title="Remove bank account template"
message={
removeAccountTarget
? `Remove “${removeAccountTarget.bankName}” from bank account templates?`
: ''
}
onCancel={() => setRemoveAccountTarget(null)}
onConfirm={() => void handleRemoveAccount()}
/>
</main> </main>
) )
} }
@@ -1,9 +1,12 @@
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import type { import type {
CreateInvoiceAccountTemplatePayload,
CreateInvoiceItemTemplatePayload, CreateInvoiceItemTemplatePayload,
CreateInvoicePayload, CreateInvoicePayload,
CreateInvoiceTemplatePayload, CreateInvoiceTemplatePayload,
Invoice, Invoice,
InvoiceAccountTemplate,
InvoiceAccountTemplatesResponse,
InvoiceItemTemplate, InvoiceItemTemplate,
InvoiceItemTemplatesResponse, InvoiceItemTemplatesResponse,
InvoiceStatus, InvoiceStatus,
@@ -11,6 +14,7 @@ import type {
InvoiceTemplatesListResponse, InvoiceTemplatesListResponse,
InvoicesListResponse, InvoicesListResponse,
PublicInvoice, PublicInvoice,
UpdateInvoiceAccountTemplatePayload,
UpdateInvoiceItemTemplatePayload, UpdateInvoiceItemTemplatePayload,
UpdateInvoicePayload, UpdateInvoicePayload,
UpdateInvoiceTemplatePayload, UpdateInvoiceTemplatePayload,
@@ -49,6 +53,39 @@ export function deleteInvoiceItemTemplate(templateId: string) {
}) })
} }
export function listInvoiceAccountTemplates(signal?: AbortSignal) {
return apiRequest<InvoiceAccountTemplatesResponse>('/invoice-account-templates', {
auth: true,
signal,
})
}
export function createInvoiceAccountTemplate(payload: CreateInvoiceAccountTemplatePayload) {
return apiRequest<InvoiceAccountTemplate>('/invoice-account-templates', {
method: 'POST',
auth: true,
body: payload,
})
}
export function updateInvoiceAccountTemplate(
templateId: string,
payload: UpdateInvoiceAccountTemplatePayload,
) {
return apiRequest<InvoiceAccountTemplate>(`/invoice-account-templates/${templateId}`, {
method: 'PATCH',
auth: true,
body: payload,
})
}
export function deleteInvoiceAccountTemplate(templateId: string) {
return apiRequest<{ ok: boolean }>(`/invoice-account-templates/${templateId}`, {
method: 'DELETE',
auth: true,
})
}
export function listInvoiceTemplates(signal?: AbortSignal) { export function listInvoiceTemplates(signal?: AbortSignal) {
return apiRequest<InvoiceTemplatesListResponse>('/invoice-templates', { return apiRequest<InvoiceTemplatesListResponse>('/invoice-templates', {
auth: true, auth: true,
+30
View File
@@ -16,6 +16,20 @@ export interface InvoiceItemTemplate {
updatedAt: string updatedAt: string
} }
export interface InvoiceAccountTemplate {
id: string
ownerScope: 'platform' | 'business'
businessId: string | null
bankName: string
accountHolderName: string | null
cardNumber: string | null
iban: string | null
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
}
export interface InvoiceItem { export interface InvoiceItem {
id: string id: string
invoiceId: string invoiceId: string
@@ -222,6 +236,10 @@ export interface InvoiceItemTemplatesResponse {
items: InvoiceItemTemplate[] items: InvoiceItemTemplate[]
} }
export interface InvoiceAccountTemplatesResponse {
items: InvoiceAccountTemplate[]
}
export interface InvoiceTemplatesListResponse { export interface InvoiceTemplatesListResponse {
items: InvoiceTemplate[] items: InvoiceTemplate[]
} }
@@ -248,5 +266,17 @@ export type UpdateInvoiceItemTemplatePayload = Partial<CreateInvoiceItemTemplate
isActive?: boolean isActive?: boolean
} }
export interface CreateInvoiceAccountTemplatePayload {
bankName: string
accountHolderName?: string
cardNumber?: string
iban?: string
sortOrder?: number
}
export type UpdateInvoiceAccountTemplatePayload = Partial<CreateInvoiceAccountTemplatePayload> & {
isActive?: boolean
}
/** @deprecated use InvoiceItemTemplatesResponse */ /** @deprecated use InvoiceItemTemplatesResponse */
export type InvoiceTemplatesResponse = InvoiceItemTemplatesResponse export type InvoiceTemplatesResponse = InvoiceItemTemplatesResponse
+21 -2
View File
@@ -1,6 +1,7 @@
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import type { import type {
InvoiceAccountInput, InvoiceAccountInput,
InvoiceAccountTemplate,
InvoiceItemInput, InvoiceItemInput,
InvoiceItemTemplate, InvoiceItemTemplate,
InvoiceKeyPointInput, InvoiceKeyPointInput,
@@ -32,6 +33,10 @@ export type DraftAccount = {
iban: string iban: string
} }
/** Default free-text payment row shown on new invoice / template drafts. */
export const DEFAULT_PAYMENT_METHOD_TEXT =
'مبلغ ۱.۰۰۰.۰۰۰ تومان پیش پرداخت در تاریخ ۱۰-۱۰-۱۴۱۰ به صورت نقدی'
export function newKey() { export function newKey() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
} }
@@ -52,8 +57,12 @@ export function emptyDraftKeyPoint(): DraftKeyPoint {
return { key: newKey(), text: '' } return { key: newKey(), text: '' }
} }
export function emptyDraftPaymentMethod(): DraftPaymentMethod { export function emptyDraftPaymentMethod(text = ''): DraftPaymentMethod {
return { key: newKey(), text: '' } return { key: newKey(), text }
}
export function defaultDraftPaymentMethods(): DraftPaymentMethod[] {
return [emptyDraftPaymentMethod(DEFAULT_PAYMENT_METHOD_TEXT)]
} }
export function emptyDraftAccount(): DraftAccount { export function emptyDraftAccount(): DraftAccount {
@@ -76,6 +85,16 @@ export function draftItemFromItemTemplate(template: InvoiceItemTemplate): DraftL
} }
} }
export function draftAccountFromAccountTemplate(template: InvoiceAccountTemplate): DraftAccount {
return {
key: newKey(),
bankName: template.bankName,
accountHolderName: template.accountHolderName ?? '',
cardNumber: template.cardNumber ?? '',
iban: template.iban ?? '',
}
}
export function draftsFromInvoiceTemplate(template: InvoiceTemplate): { export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
name: string name: string
topText: string topText: string