diff --git a/apps/business/src/components/InvoiceDraftFields.module.css b/apps/business/src/components/InvoiceDraftFields.module.css index 97ba33a..14cf0ff 100644 --- a/apps/business/src/components/InvoiceDraftFields.module.css +++ b/apps/business/src/components/InvoiceDraftFields.module.css @@ -135,6 +135,15 @@ justify-content: space-between; gap: 12px; margin-bottom: 10px; + flex-wrap: wrap; +} + +.blockHeaderActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } .blockTitle { diff --git a/apps/business/src/components/InvoiceDraftFields.tsx b/apps/business/src/components/InvoiceDraftFields.tsx index e1a5c07..7afe935 100644 --- a/apps/business/src/components/InvoiceDraftFields.tsx +++ b/apps/business/src/components/InvoiceDraftFields.tsx @@ -1,10 +1,11 @@ import { Pencil, Plus, X } from 'lucide-react' import type { ReactNode } from 'react' import { useT } from '../i18n/useT' -import type { InvoiceItemTemplate } from '../types/invoice' +import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice' import { formatIrtInput } from '../utils/irtPrice' import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft' import { + draftAccountFromAccountTemplate, draftItemFromItemTemplate, emptyDraftAccount, emptyDraftItem, @@ -28,34 +29,42 @@ function focusPaymentMethodInput(index: number) { type Props = { itemTemplates: InvoiceItemTemplate[] + accountTemplates: InvoiceAccountTemplate[] items: DraftLineItem[] keyPoints: DraftKeyPoint[] paymentMethods: DraftPaymentMethod[] accounts: DraftAccount[] selectedItemTemplateId: string onSelectedItemTemplateId: (id: string) => void + selectedAccountTemplateId: string + onSelectedAccountTemplateId: (id: string) => void onItemsChange: (items: DraftLineItem[]) => void onKeyPointsChange: (points: DraftKeyPoint[]) => void onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void onAccountsChange: (accounts: DraftAccount[]) => void showItemTemplatePicker?: boolean + showAccountTemplatePicker?: boolean /** Rendered after line items (before key points / accounts), e.g. totals row. */ afterItems?: ReactNode } export function InvoiceDraftFields({ itemTemplates, + accountTemplates, items, keyPoints, paymentMethods, accounts, selectedItemTemplateId, onSelectedItemTemplateId, + selectedAccountTemplateId, + onSelectedAccountTemplateId, onItemsChange, onKeyPointsChange, onPaymentMethodsChange, onAccountsChange, showItemTemplatePicker = true, + showAccountTemplatePicker = true, afterItems, }: Props) { const t = useT() @@ -81,6 +90,13 @@ export function InvoiceDraftFields({ onSelectedItemTemplateId('') } + function addFromAccountTemplate() { + const template = accountTemplates.find((entry) => entry.id === selectedAccountTemplateId) + if (!template) return + onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)]) + onSelectedAccountTemplateId('') + } + return ( <>
@@ -323,14 +339,44 @@ export function InvoiceDraftFields({

{t('invoiceDraft.accounts')}

- +
+ {showAccountTemplatePicker ? ( +
+ + +
+ ) : null} + +
{accounts.length === 0 ? (

{t('invoiceDraft.noAccounts')}

diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 91c4650..91cb260 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -2417,6 +2417,8 @@ const en = { 'invoiceDraft.fieldDescriptionPlaceholder': 'Optional details', 'invoiceDraft.addFromTemplate': 'Add from template…', 'invoiceDraft.addFromTemplateAria': 'Add from item template', + 'invoiceDraft.addFromAccountTemplate': 'Add from account template…', + 'invoiceDraft.addFromAccountTemplateAria': 'Add from bank account template', 'invoiceDraft.add': 'Add', 'invoiceDraft.addCustomItem': 'Add custom item', 'invoiceDraft.keyPoints': 'Key points', @@ -2444,7 +2446,7 @@ const en = { 'invoiceDraft.error.itemDiscountInvalid': 'Discounted price is invalid 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.desc': 'Full blueprints for faster issuing — pick one and edit, or start blank.', @@ -2471,29 +2473,57 @@ const en = { 'invoiceTemplates.col.price': 'Price', 'invoiceTemplates.col.discounted': 'Discounted', '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.removeTemplate': 'Remove', 'invoiceTemplates.editItem': 'Edit', 'invoiceTemplates.removeItem': 'Remove', + 'invoiceTemplates.editAccount': 'Edit', + 'invoiceTemplates.removeAccount': 'Remove', 'invoiceTemplates.deleteTemplateTitle': 'Remove invoice template', 'invoiceTemplates.deleteTemplateMessage': 'Remove "{name}" from invoice templates?', 'invoiceTemplates.deleteItemTitle': 'Remove item template', '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.itemModalEditTitle': 'Edit item template', + 'invoiceTemplates.accountModalAddTitle': 'Add bank account template', + 'invoiceTemplates.accountModalEditTitle': 'Edit bank account template', 'invoiceTemplates.itemUpdated': 'Item template updated.', 'invoiceTemplates.itemCreated': 'Item template created.', '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.loadError': 'Unable to load invoice settings.', 'invoiceTemplates.itemSaveError': 'Unable to save item template.', '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.saveItem': 'Save changes', 'invoiceTemplates.savingItem': 'Saving…', 'invoiceTemplates.createItem': 'Create item', + 'invoiceTemplates.saveAccount': 'Save changes', + 'invoiceTemplates.savingAccount': 'Saving…', + 'invoiceTemplates.createAccount': 'Create account', 'invoiceTemplates.cancel': 'Cancel', '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.editTitle': 'Edit invoice template', @@ -5043,6 +5073,8 @@ const fa: Record = { 'invoiceDraft.fieldDescriptionPlaceholder': 'جزئیات اختیاری', 'invoiceDraft.addFromTemplate': 'افزودن از قالب...', 'invoiceDraft.addFromTemplateAria': 'افزودن از قالب آیتم', + 'invoiceDraft.addFromAccountTemplate': 'افزودن از قالب حساب...', + 'invoiceDraft.addFromAccountTemplateAria': 'افزودن از قالب حساب بانکی', 'invoiceDraft.add': 'افزودن', 'invoiceDraft.addCustomItem': 'افزودن آیتم سفارشی', 'invoiceDraft.keyPoints': 'نکات کلیدی', @@ -5070,7 +5102,7 @@ const fa: Record = { 'invoiceDraft.error.itemDiscountInvalid': 'قیمت با تخفیف برای «{title}» نامعتبر است.', 'invoiceDraft.error.itemDiscountExceeds': 'قیمت با تخفیف نمی‌تواند از قیمت اصلی «{title}» بیشتر باشد.', - 'invoiceTemplates.subtitle': 'قالب‌های فاکتور و آیتم‌های از پیش تعریف‌شده را مدیریت کنید.', + 'invoiceTemplates.subtitle': 'قالب‌های فاکتور، آیتم‌ها و حساب‌های بانکی را مدیریت کنید.', 'invoiceTemplates.section.templates.title': 'قالب‌های فاکتور', 'invoiceTemplates.section.templates.desc': 'الگوهای کامل برای صدور سریع‌تر فاکتور — یکی را انتخاب کنید یا از خالی شروع کنید.', @@ -5097,29 +5129,57 @@ const fa: Record = { 'invoiceTemplates.col.price': 'قیمت', 'invoiceTemplates.col.discounted': 'با تخفیف', '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.removeTemplate': 'حذف', 'invoiceTemplates.editItem': 'ویرایش', 'invoiceTemplates.removeItem': 'حذف', + 'invoiceTemplates.editAccount': 'ویرایش', + 'invoiceTemplates.removeAccount': 'حذف', 'invoiceTemplates.deleteTemplateTitle': 'حذف قالب فاکتور', 'invoiceTemplates.deleteTemplateMessage': '«{name}» از قالب‌های فاکتور حذف شود؟', 'invoiceTemplates.deleteItemTitle': 'حذف قالب آیتم', 'invoiceTemplates.deleteItemMessage': '«{title}» از آیتم‌های از پیش تعریف‌شده حذف شود؟', + 'invoiceTemplates.deleteAccountTitle': 'حذف قالب حساب بانکی', + 'invoiceTemplates.deleteAccountMessage': '«{name}» از قالب‌های حساب بانکی حذف شود؟', 'invoiceTemplates.itemModalAddTitle': 'افزودن قالب آیتم', 'invoiceTemplates.itemModalEditTitle': 'ویرایش قالب آیتم', + 'invoiceTemplates.accountModalAddTitle': 'افزودن قالب حساب بانکی', + 'invoiceTemplates.accountModalEditTitle': 'ویرایش قالب حساب بانکی', 'invoiceTemplates.itemUpdated': 'قالب آیتم به‌روزرسانی شد.', 'invoiceTemplates.itemCreated': 'قالب آیتم ایجاد شد.', 'invoiceTemplates.itemRemoved': 'قالب آیتم حذف شد.', + 'invoiceTemplates.accountUpdated': 'قالب حساب بانکی به‌روزرسانی شد.', + 'invoiceTemplates.accountCreated': 'قالب حساب بانکی ایجاد شد.', + 'invoiceTemplates.accountRemoved': 'قالب حساب بانکی حذف شد.', 'invoiceTemplates.templateRemoved': 'قالب فاکتور حذف شد.', 'invoiceTemplates.loadError': 'بارگذاری تنظیمات فاکتور ممکن نشد.', 'invoiceTemplates.itemSaveError': 'ذخیره قالب آیتم ممکن نشد.', 'invoiceTemplates.itemRemoveError': 'حذف آیتم ممکن نشد.', + 'invoiceTemplates.accountSaveError': 'ذخیره قالب حساب بانکی ممکن نشد.', + 'invoiceTemplates.accountRemoveError': 'حذف حساب بانکی ممکن نشد.', + 'invoiceTemplates.accountBankRequired': 'نام بانک الزامی است.', 'invoiceTemplates.templateRemoveError': 'حذف قالب ممکن نشد.', 'invoiceTemplates.saveItem': 'ذخیره تغییرات', 'invoiceTemplates.savingItem': 'در حال ذخیره...', 'invoiceTemplates.createItem': 'ایجاد آیتم', + 'invoiceTemplates.saveAccount': 'ذخیره تغییرات', + 'invoiceTemplates.savingAccount': 'در حال ذخیره...', + 'invoiceTemplates.createAccount': 'ایجاد حساب', 'invoiceTemplates.cancel': 'انصراف', 'invoiceTemplates.hint': 'نکته: از صفحه فاکتورها با استفاده از یک قالب یا از ابتدا فاکتور صادر کنید.', + 'invoiceTemplates.expandSection': 'نمایش فهرست', + 'invoiceTemplates.collapseSection': 'بستن فهرست', 'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور', 'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور', diff --git a/apps/business/src/pages/InvoiceTemplateEditorPage.tsx b/apps/business/src/pages/InvoiceTemplateEditorPage.tsx index 95c2dab..c7a94ba 100644 --- a/apps/business/src/pages/InvoiceTemplateEditorPage.tsx +++ b/apps/business/src/pages/InvoiceTemplateEditorPage.tsx @@ -9,16 +9,18 @@ import { ApiError, isAbortError } from '../lib/api' import { createInvoiceTemplate, getInvoiceTemplate, + listInvoiceAccountTemplates, listInvoiceItemTemplates, updateInvoiceTemplate, } from '../services/invoiceService' -import type { InvoiceItemTemplate } from '../types/invoice' +import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice' import { buildAccountsPayload, buildKeyPointsPayload, buildPaymentMethodsPayload, buildTemplateItemsPayload, draftsFromInvoiceTemplate, + defaultDraftPaymentMethods, emptyDraftItem, type DraftAccount, type DraftKeyPoint, @@ -37,15 +39,19 @@ export function InvoiceTemplateEditorPage() { const t = useT() const [itemTemplates, setItemTemplates] = useState([]) + const [accountTemplates, setAccountTemplates] = useState([]) const [loading, setLoading] = useState(isEdit) const [error, setError] = useState('') const [name, setName] = useState('') const [topText, setTopText] = useState('') const [items, setItems] = useState([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) - const [paymentMethods, setPaymentMethods] = useState([]) + const [paymentMethods, setPaymentMethods] = useState(() => + isEdit ? [] : defaultDraftPaymentMethods(), + ) const [accounts, setAccounts] = useState([]) const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') + const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('') const [submitting, setSubmitting] = useState(false) const [formError, setFormError] = useState('') @@ -56,8 +62,12 @@ export function InvoiceTemplateEditorPage() { setLoading(true) setError('') try { - const itemsRes = await listInvoiceItemTemplates(controller.signal) + const [itemsRes, accountsRes] = await Promise.all([ + listInvoiceItemTemplates(controller.signal), + listInvoiceAccountTemplates(controller.signal), + ]) setItemTemplates(itemsRes.items.filter((i) => i.isActive)) + setAccountTemplates(accountsRes.items.filter((i) => i.isActive)) if (templateId) { const template = await getInvoiceTemplate(templateId, controller.signal) @@ -166,12 +176,15 @@ export function InvoiceTemplateEditorPage() { void + icon: ReactNode + title: string + subtitle: string + action?: ReactNode + children: ReactNode +}) { + const t = useT() + return ( +
+
+ + {open && action ?
{action}
: null} +
+ {open ? ( +
+ {children} +
+ ) : null} +
+ ) +} + export function InvoiceTemplatesPage() { const navigate = useNavigate() const { showToast } = useToast() const t = useT() + const [openSection, setOpenSection] = useState('templates') const [itemTemplates, setItemTemplates] = useState([]) + const [accountTemplates, setAccountTemplates] = useState([]) const [invoiceTemplates, setInvoiceTemplates] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -76,6 +166,17 @@ export function InvoiceTemplatesPage() { const [removeItemTarget, setRemoveItemTarget] = useState(null) const [removeTplTarget, setRemoveTplTarget] = useState(null) + const [accountEditorOpen, setAccountEditorOpen] = useState(false) + const [editingAccount, setEditingAccount] = useState(null) + const [accountDraft, setAccountDraft] = useState(EMPTY_ACCOUNT_DRAFT) + const [accountSubmitting, setAccountSubmitting] = useState(false) + const [accountFormError, setAccountFormError] = useState('') + const [removeAccountTarget, setRemoveAccountTarget] = useState(null) + + function toggleSection(id: SectionId) { + setOpenSection(id) + } + function itemDraftToPayload(draft: ItemDraft) { const price = parseIrtInput(draft.price) if (!draft.title.trim()) throw new Error(t('invoiceDraft.error.itemTitle')) @@ -97,15 +198,27 @@ export function InvoiceTemplatesPage() { } } + function accountDraftToPayload(draft: AccountDraft) { + if (!draft.bankName.trim()) throw new Error(t('invoiceTemplates.accountBankRequired')) + return { + bankName: draft.bankName.trim(), + accountHolderName: draft.accountHolderName.trim() || undefined, + cardNumber: draft.cardNumber.trim() || undefined, + iban: draft.iban.trim() || undefined, + } + } + async function reload(signal?: AbortSignal) { setLoading(true) setError('') try { - const [items, templates] = await Promise.all([ + const [items, accounts, templates] = await Promise.all([ listInvoiceItemTemplates(signal), + listInvoiceAccountTemplates(signal), listInvoiceTemplates(signal), ]) setItemTemplates(items.items) + setAccountTemplates(accounts.items) setInvoiceTemplates(templates.items) } catch (err) { if (isAbortError(err)) return @@ -187,6 +300,64 @@ export function InvoiceTemplatesPage() { } } + function openCreateAccount() { + setEditingAccount(null) + setAccountDraft(EMPTY_ACCOUNT_DRAFT) + setAccountFormError('') + setAccountEditorOpen(true) + } + + function openEditAccount(template: InvoiceAccountTemplate) { + setEditingAccount(template) + setAccountDraft(accountDraftFromTemplate(template)) + setAccountFormError('') + setAccountEditorOpen(true) + } + + async function handleSaveAccount() { + setAccountFormError('') + let payload + try { + payload = accountDraftToPayload(accountDraft) + } catch (err) { + setAccountFormError(err instanceof Error ? err.message : t('invoiceTemplates.accountSaveError')) + return + } + setAccountSubmitting(true) + try { + if (editingAccount) { + await updateInvoiceAccountTemplate(editingAccount.id, payload) + showToast(t('invoiceTemplates.accountUpdated'), 'success') + } else { + await createInvoiceAccountTemplate(payload) + showToast(t('invoiceTemplates.accountCreated'), 'success') + } + setAccountEditorOpen(false) + await reload() + } catch (err) { + setAccountFormError( + err instanceof ApiError ? err.message : t('invoiceTemplates.accountSaveError'), + ) + } finally { + setAccountSubmitting(false) + } + } + + async function handleRemoveAccount() { + if (!removeAccountTarget) return + try { + await deleteInvoiceAccountTemplate(removeAccountTarget.id) + showToast(t('invoiceTemplates.accountRemoved'), 'success') + setRemoveAccountTarget(null) + await reload() + } catch (err) { + showToast( + err instanceof ApiError ? err.message : t('invoiceTemplates.accountRemoveError'), + 'error', + ) + } + } + return (
{error}

: null} -
-
-
- -
-

{t('invoiceTemplates.section.templates.title')}

-

{t('invoiceTemplates.section.templates.desc')}

-
-
+ toggleSection('templates')} + icon={} + title={t('invoiceTemplates.section.templates.title')} + subtitle={t('invoiceTemplates.section.templates.desc')} + action={ -
- + } + >
@@ -310,17 +480,16 @@ export function InvoiceTemplatesPage() {
-
+ -
-
-
- -
-

{t('invoiceTemplates.section.items.title')}

-

{t('invoiceTemplates.section.items.desc')}

-
-
+ toggleSection('items')} + icon={} + title={t('invoiceTemplates.section.items.title')} + subtitle={t('invoiceTemplates.section.items.desc')} + action={ -
- + } + >
@@ -417,7 +586,103 @@ export function InvoiceTemplatesPage() {
-
+ + + toggleSection('accounts')} + icon={} + title={t('invoiceTemplates.section.accounts.title')} + subtitle={t('invoiceTemplates.section.accounts.desc')} + action={ + + } + > +
+
+
+ {loading + ? t('invoices.loading') + : accountTemplates.length === 1 + ? t('invoiceTemplates.countAccountsOne', { count: accountTemplates.length }) + : t('invoiceTemplates.countAccountsMany', { count: accountTemplates.length })} +
+
+ + + + + + + + + + + + + + + + + + + {!loading && accountTemplates.length === 0 ? ( + + ) : null} + {accountTemplates.map((template) => ( + + + + + + + + ))} + +
{t('invoiceTemplates.col.bankName')}{t('invoiceTemplates.col.accountHolder')}{t('invoiceTemplates.col.cardNumber')}{t('invoiceTemplates.col.iban')} + {t('invoiceTemplates.col.actions')} +
+
{template.bankName}
+
{template.accountHolderName || '—'}{template.cardNumber || '—'}{template.iban || '—'} +
+ + + + + + +
+
+
+

{t('invoiceTemplates.hint')}

@@ -513,6 +778,79 @@ export function InvoiceTemplatesPage() {
+ !accountSubmitting && setAccountEditorOpen(false)} + wide + > +
+
+ + setAccountDraft((d) => ({ ...d, bankName: e.target.value }))} + placeholder={t('invoiceDraft.bankName')} + /> +
+
+ + setAccountDraft((d) => ({ ...d, accountHolderName: e.target.value }))} + placeholder={t('invoiceDraft.accountHolder')} + /> +
+
+ + setAccountDraft((d) => ({ ...d, cardNumber: e.target.value }))} + placeholder={t('invoiceDraft.optional')} + /> +
+
+ + setAccountDraft((d) => ({ ...d, iban: e.target.value }))} + placeholder={t('invoiceDraft.optional')} + /> +
+
+ {accountFormError ?

{accountFormError}

: null} +
+ + +
+
+ setRemoveItemTarget(null)} onConfirm={() => void handleRemoveItem()} /> + + setRemoveAccountTarget(null)} + onConfirm={() => void handleRemoveAccount()} + /> ) } diff --git a/apps/business/src/pages/IssueInvoicePage.tsx b/apps/business/src/pages/IssueInvoicePage.tsx index 27516b2..1ffbabd 100644 --- a/apps/business/src/pages/IssueInvoicePage.tsx +++ b/apps/business/src/pages/IssueInvoicePage.tsx @@ -10,12 +10,13 @@ import { ApiError, isAbortError } from '../lib/api' import { createInvoice, getInvoice, + listInvoiceAccountTemplates, listInvoiceItemTemplates, listInvoiceTemplates, updateInvoice, } from '../services/invoiceService' import { listCustomers, type BusinessCustomer } from '../services/customerService' -import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice' +import type { InvoiceAccountTemplate, InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice' import { buildAccountsPayload, buildKeyPointsPayload, @@ -23,6 +24,7 @@ import { buildPaymentMethodsPayload, draftsFromInvoice, draftsFromInvoiceTemplate, + defaultDraftPaymentMethods, emptyDraftItem, type DraftAccount, type DraftKeyPoint, @@ -72,6 +74,7 @@ export function IssueInvoicePage() { ?.presetCustomer const [itemTemplates, setItemTemplates] = useState([]) + const [accountTemplates, setAccountTemplates] = useState([]) const [invoiceTemplates, setInvoiceTemplates] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') @@ -90,9 +93,12 @@ export function IssueInvoicePage() { const [notes, setNotes] = useState('') const [draftItems, setDraftItems] = useState([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) - const [paymentMethods, setPaymentMethods] = useState([]) + const [paymentMethods, setPaymentMethods] = useState(() => + isEdit ? [] : defaultDraftPaymentMethods(), + ) const [accounts, setAccounts] = useState([]) const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') + const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('') const [submitting, setSubmitting] = useState(false) const [formError, setFormError] = useState('') @@ -114,11 +120,13 @@ export function IssueInvoicePage() { setError('') setLocked(false) try { - const [items, templates] = await Promise.all([ + const [items, accountsRes, templates] = await Promise.all([ listInvoiceItemTemplates(controller.signal), + listInvoiceAccountTemplates(controller.signal), listInvoiceTemplates(controller.signal), ]) setItemTemplates(items.items.filter((i) => i.isActive)) + setAccountTemplates(accountsRes.items.filter((i) => i.isActive)) setInvoiceTemplates(templates.items.filter((i) => i.isActive)) if (invoiceId) { @@ -142,6 +150,7 @@ export function IssueInvoicePage() { setPaymentMethods(drafts.paymentMethods) setAccounts(drafts.accounts) setSelectedItemTemplateId('') + setSelectedAccountTemplateId('') } else if (urlUserId && !presetCustomer) { const listed = await listCustomers({ page: 1, pageSize: 100 }, controller.signal) const match = listed.items.find((c) => c.id === urlUserId) @@ -178,9 +187,10 @@ export function IssueInvoicePage() { setNotes('') setDraftItems([emptyDraftItem()]) setKeyPoints([]) - setPaymentMethods([]) + setPaymentMethods(defaultDraftPaymentMethods()) setAccounts([]) setSelectedItemTemplateId('') + setSelectedAccountTemplateId('') setFormError('') } @@ -202,6 +212,7 @@ export function IssueInvoicePage() { setPaymentMethods(drafts.paymentMethods) setAccounts(drafts.accounts) setSelectedItemTemplateId('') + setSelectedAccountTemplateId('') setFormError('') } @@ -360,12 +371,15 @@ export function IssueInvoicePage() { (businessPath('/invoice-account-templates'), { + auth: true, + signal, + }) +} + +export function createInvoiceAccountTemplate(payload: CreateInvoiceAccountTemplatePayload) { + return apiRequest(businessPath('/invoice-account-templates'), { + method: 'POST', + auth: true, + body: payload, + }) +} + +export function updateInvoiceAccountTemplate( + templateId: string, + payload: UpdateInvoiceAccountTemplatePayload, +) { + return apiRequest( + 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) { return apiRequest(businessPath('/invoice-templates'), { auth: true, diff --git a/apps/business/src/types/invoice.ts b/apps/business/src/types/invoice.ts index ce8d4e0..3d7861c 100644 --- a/apps/business/src/types/invoice.ts +++ b/apps/business/src/types/invoice.ts @@ -16,6 +16,20 @@ export interface InvoiceItemTemplate { 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 { id: string invoiceId: string @@ -198,6 +212,10 @@ export interface InvoiceItemTemplatesResponse { items: InvoiceItemTemplate[] } +export interface InvoiceAccountTemplatesResponse { + items: InvoiceAccountTemplate[] +} + export interface InvoiceTemplatesListResponse { items: InvoiceTemplate[] } @@ -223,3 +241,15 @@ export interface CreateInvoiceItemTemplatePayload { export type UpdateInvoiceItemTemplatePayload = Partial & { isActive?: boolean } + +export interface CreateInvoiceAccountTemplatePayload { + bankName: string + accountHolderName?: string + cardNumber?: string + iban?: string + sortOrder?: number +} + +export type UpdateInvoiceAccountTemplatePayload = Partial & { + isActive?: boolean +} diff --git a/apps/business/src/utils/invoiceDraft.ts b/apps/business/src/utils/invoiceDraft.ts index 53376dc..c7bda19 100644 --- a/apps/business/src/utils/invoiceDraft.ts +++ b/apps/business/src/utils/invoiceDraft.ts @@ -2,6 +2,7 @@ import { formatIrtInput, parseIrtInput } from './irtPrice' import type { BusinessMessageKey } from '../i18n/messages' import type { InvoiceAccountInput, + InvoiceAccountTemplate, InvoiceItemInput, InvoiceItemTemplate, InvoiceKeyPointInput, @@ -51,6 +52,10 @@ export type DraftAccount = { iban: string } +/** Default free-text payment row shown on new invoice / template drafts. */ +export const DEFAULT_PAYMENT_METHOD_TEXT = + 'مبلغ ۱.۰۰۰.۰۰۰ تومان پیش پرداخت در تاریخ ۱۰-۱۰-۱۴۱۰ به صورت نقدی' + export function newKey() { return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } @@ -71,8 +76,12 @@ export function emptyDraftKeyPoint(): DraftKeyPoint { return { key: newKey(), text: '' } } -export function emptyDraftPaymentMethod(): DraftPaymentMethod { - return { key: newKey(), text: '' } +export function emptyDraftPaymentMethod(text = ''): DraftPaymentMethod { + return { key: newKey(), text } +} + +export function defaultDraftPaymentMethods(): DraftPaymentMethod[] { + return [emptyDraftPaymentMethod(DEFAULT_PAYMENT_METHOD_TEXT)] } 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): { name: string topText: string diff --git a/apps/super-admin/src/components/InvoiceDraftFields.tsx b/apps/super-admin/src/components/InvoiceDraftFields.tsx index 24c7476..69c90f2 100644 --- a/apps/super-admin/src/components/InvoiceDraftFields.tsx +++ b/apps/super-admin/src/components/InvoiceDraftFields.tsx @@ -1,8 +1,9 @@ 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 type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft' import { + draftAccountFromAccountTemplate, draftItemFromItemTemplate, emptyDraftAccount, emptyDraftItem, @@ -26,32 +27,40 @@ function focusPaymentMethodInput(index: number) { type Props = { itemTemplates: InvoiceItemTemplate[] + accountTemplates: InvoiceAccountTemplate[] items: DraftLineItem[] keyPoints: DraftKeyPoint[] paymentMethods: DraftPaymentMethod[] accounts: DraftAccount[] selectedItemTemplateId: string onSelectedItemTemplateId: (id: string) => void + selectedAccountTemplateId: string + onSelectedAccountTemplateId: (id: string) => void onItemsChange: (items: DraftLineItem[]) => void onKeyPointsChange: (points: DraftKeyPoint[]) => void onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void onAccountsChange: (accounts: DraftAccount[]) => void showItemTemplatePicker?: boolean + showAccountTemplatePicker?: boolean } export function InvoiceDraftFields({ itemTemplates, + accountTemplates, items, keyPoints, paymentMethods, accounts, selectedItemTemplateId, onSelectedItemTemplateId, + selectedAccountTemplateId, + onSelectedAccountTemplateId, onItemsChange, onKeyPointsChange, onPaymentMethodsChange, onAccountsChange, showItemTemplatePicker = true, + showAccountTemplatePicker = true, }: Props) { function updateItem(key: string, patch: Partial) { onItemsChange(items.map((item) => (item.key === key ? { ...item, ...patch } : item))) @@ -74,6 +83,13 @@ export function InvoiceDraftFields({ onSelectedItemTemplateId('') } + function addFromAccountTemplate() { + const template = accountTemplates.find((t) => t.id === selectedAccountTemplateId) + if (!template) return + onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)]) + onSelectedAccountTemplateId('') + } + return ( <>
@@ -314,14 +330,44 @@ export function InvoiceDraftFields({

Account numbers

- +
+ {showAccountTemplatePicker ? ( +
+ + +
+ ) : null} + +
{accounts.length === 0 ? (

No bank accounts yet.

diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css index 2c0b2f4..113fd0c 100644 --- a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css +++ b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css @@ -424,6 +424,15 @@ justify-content: space-between; gap: 12px; margin-bottom: 10px; + flex-wrap: wrap; +} + +.blockHeaderActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } .blockTitle { diff --git a/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx b/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx index c54cea6..a467e10 100644 --- a/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx +++ b/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx @@ -8,15 +8,17 @@ import { ApiError, isAbortError } from '../lib/api' import { createInvoiceTemplate, getInvoiceTemplate, + listInvoiceAccountTemplates, listInvoiceItemTemplates, updateInvoiceTemplate, } from '../services/invoiceService' -import type { InvoiceItemTemplate } from '../types/invoice' +import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice' import { buildAccountsPayload, buildKeyPointsPayload, buildPaymentMethodsPayload, buildTemplateItemsPayload, + defaultDraftPaymentMethods, draftsFromInvoiceTemplate, emptyDraftItem, type DraftAccount, @@ -36,15 +38,19 @@ export function InvoiceTemplateEditorPage() { const { showToast } = useToast() const [itemTemplates, setItemTemplates] = useState([]) + const [accountTemplates, setAccountTemplates] = useState([]) const [loading, setLoading] = useState(isEdit) const [error, setError] = useState('') const [name, setName] = useState('') const [topText, setTopText] = useState('') const [items, setItems] = useState([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) - const [paymentMethods, setPaymentMethods] = useState([]) + const [paymentMethods, setPaymentMethods] = useState(() => + defaultDraftPaymentMethods(), + ) const [accounts, setAccounts] = useState([]) const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') + const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('') const [submitting, setSubmitting] = useState(false) const [formError, setFormError] = useState('') @@ -55,8 +61,12 @@ export function InvoiceTemplateEditorPage() { setLoading(true) setError('') try { - const itemsRes = await listInvoiceItemTemplates(controller.signal) + const [itemsRes, accountsRes] = await Promise.all([ + listInvoiceItemTemplates(controller.signal), + listInvoiceAccountTemplates(controller.signal), + ]) setItemTemplates(itemsRes.items.filter((t) => t.isActive)) + setAccountTemplates(accountsRes.items.filter((t) => t.isActive)) if (templateId) { const template = await getInvoiceTemplate(templateId, controller.signal) @@ -167,12 +177,15 @@ export function InvoiceTemplateEditorPage() { (null) const [itemTemplates, setItemTemplates] = useState([]) + const [accountTemplates, setAccountTemplates] = useState([]) const [invoiceTemplates, setInvoiceTemplates] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') @@ -60,9 +63,12 @@ export function IssueInvoicePage() { const [notes, setNotes] = useState('') const [draftItems, setDraftItems] = useState([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) - const [paymentMethods, setPaymentMethods] = useState([]) + const [paymentMethods, setPaymentMethods] = useState(() => + defaultDraftPaymentMethods(), + ) const [accounts, setAccounts] = useState([]) const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') + const [selectedAccountTemplateId, setSelectedAccountTemplateId] = useState('') const [submitting, setSubmitting] = useState(false) const [formError, setFormError] = useState('') @@ -87,13 +93,15 @@ export function IssueInvoicePage() { setError('') setLocked(false) try { - const [biz, items, templates] = await Promise.all([ + const [biz, items, accountsRes, templates] = await Promise.all([ getBusiness(businessId, controller.signal), listInvoiceItemTemplates(controller.signal), + listInvoiceAccountTemplates(controller.signal), listInvoiceTemplates(controller.signal), ]) setBusiness(biz) setItemTemplates(items.items.filter((t) => t.isActive)) + setAccountTemplates(accountsRes.items.filter((t) => t.isActive)) setInvoiceTemplates(templates.items.filter((t) => t.isActive)) if (invoiceId) { @@ -113,6 +121,7 @@ export function IssueInvoicePage() { setPaymentMethods(drafts.paymentMethods) setAccounts(drafts.accounts) setSelectedItemTemplateId('') + setSelectedAccountTemplateId('') } } catch (err) { if (isAbortError(err)) return @@ -134,9 +143,10 @@ export function IssueInvoicePage() { setNotes('') setDraftItems([emptyDraftItem()]) setKeyPoints([]) - setPaymentMethods([]) + setPaymentMethods(defaultDraftPaymentMethods()) setAccounts([]) setSelectedItemTemplateId('') + setSelectedAccountTemplateId('') setFormError('') } @@ -158,6 +168,7 @@ export function IssueInvoicePage() { setPaymentMethods(drafts.paymentMethods) setAccounts(drafts.accounts) setSelectedItemTemplateId('') + setSelectedAccountTemplateId('') setFormError('') } @@ -306,12 +317,15 @@ export function IssueInvoicePage() { void + icon: ReactNode + title: string + subtitle: string + action?: ReactNode + children: ReactNode +}) { + return ( +
+
+ + {open && action ?
{action}
: null} +
+ {open ? ( +
+ {children} +
+ ) : null} +
+ ) +} + export function SettingsPage() { const navigate = useNavigate() const { showToast } = useToast() + const [openSection, setOpenSection] = useState('templates') const [itemTemplates, setItemTemplates] = useState([]) + const [accountTemplates, setAccountTemplates] = useState([]) const [invoiceTemplates, setInvoiceTemplates] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -92,15 +198,30 @@ export function SettingsPage() { const [removeItemTarget, setRemoveItemTarget] = useState(null) const [removeTplTarget, setRemoveTplTarget] = useState(null) + const [accountEditorOpen, setAccountEditorOpen] = useState(false) + const [editingAccount, setEditingAccount] = useState(null) + const [accountDraft, setAccountDraft] = useState(EMPTY_ACCOUNT_DRAFT) + const [accountSubmitting, setAccountSubmitting] = useState(false) + const [accountFormError, setAccountFormError] = useState('') + const [removeAccountTarget, setRemoveAccountTarget] = useState( + null, + ) + + function toggleSection(id: SectionId) { + setOpenSection((current) => (current === id ? null : id)) + } + async function reload(signal?: AbortSignal) { setLoading(true) setError('') try { - const [items, templates] = await Promise.all([ + const [items, accounts, templates] = await Promise.all([ listInvoiceItemTemplates(signal), + listInvoiceAccountTemplates(signal), listInvoiceTemplates(signal), ]) setItemTemplates(items.items) + setAccountTemplates(accounts.items) setInvoiceTemplates(templates.items) } catch (err) { if (isAbortError(err)) return @@ -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 (
@@ -194,18 +370,14 @@ export function SettingsPage() { {error ?

{error}

: null} -
-
-
- -
-

Invoice templates

-

- Full blueprints (name, top text, items, key points, bank accounts) used when issuing - invoices to businesses. Pick one and edit, or start blank. -

-
-
+ toggleSection('templates')} + icon={} + title="Invoice templates" + subtitle="Full blueprints (name, top text, items, key points, bank accounts) used when issuing invoices to businesses. Pick one and edit, or start blank." + action={ -
- + } + >
Templates
@@ -250,7 +422,10 @@ export function SettingsPage() {
{template.name}
{template.topText && !isEmptyRichText(template.topText) ? ( -
+
{richTextToPlain(template.topText)}
) : null} @@ -285,19 +460,16 @@ export function SettingsPage() {
-
+ -
-
-
- -
-

Invoice item templates

-

- Reusable line items you can drop into invoice templates or when issuing an invoice. -

-
-
+ toggleSection('items')} + icon={} + title="Invoice item templates" + subtitle="Reusable line items you can drop into invoice templates or when issuing an invoice." + action={ -
- + } + >
Predefined line items
@@ -381,7 +553,91 @@ export function SettingsPage() {
-
+ + + toggleSection('accounts')} + icon={} + title="Bank account templates" + subtitle="Reusable bank accounts you can drop into invoice templates or when issuing an invoice." + action={ + + } + > +
+
+
Bank accounts
+
+ {loading + ? 'Loading…' + : `${accountTemplates.length} account${accountTemplates.length === 1 ? '' : 's'}`} +
+
+ + + + + + + + + + + + {!loading && accountTemplates.length === 0 ? ( + + ) : null} + {accountTemplates.map((template) => ( + + + + + + + + ))} + +
BankAccount holderCard numberIBANActions
+
{template.bankName}
+
{template.accountHolderName || '—'}{template.cardNumber || '—'}{template.iban || '—'} +
+ + +
+
+
+

Tip: open a business from Businesses to issue an invoice from a @@ -478,6 +734,77 @@ export function SettingsPage() {

+ !accountSubmitting && setAccountEditorOpen(false)} + wide + > +
+
+ + setAccountDraft((d) => ({ ...d, bankName: e.target.value }))} + placeholder="e.g. Mellat" + /> +
+
+ + + setAccountDraft((d) => ({ ...d, accountHolderName: e.target.value })) + } + placeholder="Optional" + /> +
+
+ + setAccountDraft((d) => ({ ...d, cardNumber: e.target.value }))} + placeholder="Optional" + /> +
+
+ + setAccountDraft((d) => ({ ...d, iban: e.target.value }))} + placeholder="Optional" + /> +
+
+ {accountFormError ?

{accountFormError}

: null} +
+ + +
+
+ setRemoveItemTarget(null)} onConfirm={() => void handleRemoveItem()} /> + + setRemoveAccountTarget(null)} + onConfirm={() => void handleRemoveAccount()} + />
) } diff --git a/apps/super-admin/src/services/invoiceService.ts b/apps/super-admin/src/services/invoiceService.ts index 8c5f2a6..6a0488e 100644 --- a/apps/super-admin/src/services/invoiceService.ts +++ b/apps/super-admin/src/services/invoiceService.ts @@ -1,9 +1,12 @@ import { apiRequest } from '../lib/api' import type { + CreateInvoiceAccountTemplatePayload, CreateInvoiceItemTemplatePayload, CreateInvoicePayload, CreateInvoiceTemplatePayload, Invoice, + InvoiceAccountTemplate, + InvoiceAccountTemplatesResponse, InvoiceItemTemplate, InvoiceItemTemplatesResponse, InvoiceStatus, @@ -11,6 +14,7 @@ import type { InvoiceTemplatesListResponse, InvoicesListResponse, PublicInvoice, + UpdateInvoiceAccountTemplatePayload, UpdateInvoiceItemTemplatePayload, UpdateInvoicePayload, UpdateInvoiceTemplatePayload, @@ -49,6 +53,39 @@ export function deleteInvoiceItemTemplate(templateId: string) { }) } +export function listInvoiceAccountTemplates(signal?: AbortSignal) { + return apiRequest('/invoice-account-templates', { + auth: true, + signal, + }) +} + +export function createInvoiceAccountTemplate(payload: CreateInvoiceAccountTemplatePayload) { + return apiRequest('/invoice-account-templates', { + method: 'POST', + auth: true, + body: payload, + }) +} + +export function updateInvoiceAccountTemplate( + templateId: string, + payload: UpdateInvoiceAccountTemplatePayload, +) { + return apiRequest(`/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) { return apiRequest('/invoice-templates', { auth: true, diff --git a/apps/super-admin/src/types/invoice.ts b/apps/super-admin/src/types/invoice.ts index c727ed6..29d44fe 100644 --- a/apps/super-admin/src/types/invoice.ts +++ b/apps/super-admin/src/types/invoice.ts @@ -16,6 +16,20 @@ export interface InvoiceItemTemplate { 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 { id: string invoiceId: string @@ -222,6 +236,10 @@ export interface InvoiceItemTemplatesResponse { items: InvoiceItemTemplate[] } +export interface InvoiceAccountTemplatesResponse { + items: InvoiceAccountTemplate[] +} + export interface InvoiceTemplatesListResponse { items: InvoiceTemplate[] } @@ -248,5 +266,17 @@ export type UpdateInvoiceItemTemplatePayload = Partial & { + isActive?: boolean +} + /** @deprecated use InvoiceItemTemplatesResponse */ export type InvoiceTemplatesResponse = InvoiceItemTemplatesResponse diff --git a/apps/super-admin/src/utils/invoiceDraft.ts b/apps/super-admin/src/utils/invoiceDraft.ts index 17191f1..6abc3ba 100644 --- a/apps/super-admin/src/utils/invoiceDraft.ts +++ b/apps/super-admin/src/utils/invoiceDraft.ts @@ -1,6 +1,7 @@ import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' import type { InvoiceAccountInput, + InvoiceAccountTemplate, InvoiceItemInput, InvoiceItemTemplate, InvoiceKeyPointInput, @@ -32,6 +33,10 @@ export type DraftAccount = { iban: string } +/** Default free-text payment row shown on new invoice / template drafts. */ +export const DEFAULT_PAYMENT_METHOD_TEXT = + 'مبلغ ۱.۰۰۰.۰۰۰ تومان پیش پرداخت در تاریخ ۱۰-۱۰-۱۴۱۰ به صورت نقدی' + export function newKey() { return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } @@ -52,8 +57,12 @@ export function emptyDraftKeyPoint(): DraftKeyPoint { return { key: newKey(), text: '' } } -export function emptyDraftPaymentMethod(): DraftPaymentMethod { - return { key: newKey(), text: '' } +export function emptyDraftPaymentMethod(text = ''): DraftPaymentMethod { + return { key: newKey(), text } +} + +export function defaultDraftPaymentMethods(): DraftPaymentMethod[] { + return [emptyDraftPaymentMethod(DEFAULT_PAYMENT_METHOD_TEXT)] } 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): { name: string topText: string