Add bank account templates and default invoice payment text.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8465a29ce1
commit
ff419603bf
@@ -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>
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user