Add bank account templates and default invoice payment text.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-21 23:31:58 +03:30
co-authored by Cursor
parent 8465a29ce1
commit ff419603bf
19 changed files with 1316 additions and 107 deletions
@@ -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<DraftLineItem>) {
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 (
<>
<div className={styles.itemsStack}>
@@ -314,14 +330,44 @@ export function InvoiceDraftFields({
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>Account numbers</h4>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
>
<Plus size={16} />
Add account
</button>
<div className={styles.blockHeaderActions}>
{showAccountTemplatePicker ? (
<div className={styles.itemActionsLeft}>
<select
id="account-template-pick"
className={styles.compactSelect}
value={selectedAccountTemplateId}
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>
{accounts.length === 0 ? (
<p className={styles.templateHint}>No bank accounts yet.</p>
@@ -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 {
@@ -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<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [loading, setLoading] = useState(isEdit)
const [error, setError] = useState('')
const [name, setName] = useState('')
const [topText, setTopText] = useState('')
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
defaultDraftPaymentMethods(),
)
const [accounts, setAccounts] = useState<DraftAccount[]>([])
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() {
<InvoiceDraftFields
itemTemplates={itemTemplates}
accountTemplates={accountTemplates}
items={items}
keyPoints={keyPoints}
paymentMethods={paymentMethods}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
selectedAccountTemplateId={selectedAccountTemplateId}
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
onItemsChange={setItems}
onKeyPointsChange={setKeyPoints}
onPaymentMethodsChange={setPaymentMethods}
@@ -9,16 +9,18 @@ import { getBusiness, type BusinessDetail } from '../services/businessService'
import {
createBusinessInvoice,
getBusinessInvoice,
listInvoiceAccountTemplates,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateBusinessInvoice,
} from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import type { InvoiceAccountTemplate, InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildLineItemsPayload,
buildPaymentMethodsPayload,
defaultDraftPaymentMethods,
draftsFromInvoice,
draftsFromInvoiceTemplate,
emptyDraftItem,
@@ -48,6 +50,7 @@ export function IssueInvoicePage() {
const [business, setBusiness] = useState<BusinessDetail | null>(null)
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -60,9 +63,12 @@ export function IssueInvoicePage() {
const [notes, setNotes] = useState('')
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>(() =>
defaultDraftPaymentMethods(),
)
const [accounts, setAccounts] = useState<DraftAccount[]>([])
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() {
<InvoiceDraftFields
itemTemplates={itemTemplates}
accountTemplates={accountTemplates}
items={draftItems}
keyPoints={keyPoints}
paymentMethods={paymentMethods}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
selectedAccountTemplateId={selectedAccountTemplateId}
onSelectedAccountTemplateId={setSelectedAccountTemplateId}
onItemsChange={setDraftItems}
onKeyPointsChange={setKeyPoints}
onPaymentMethodsChange={setPaymentMethods}
@@ -17,22 +17,47 @@
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
margin-bottom: 0;
}
.sectionToggle {
display: flex;
align-items: flex-start;
gap: 8px;
flex: 1;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
text-align: inherit;
cursor: pointer;
color: inherit;
}
.sectionChevron {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 2px;
color: var(--text-secondary);
transition: transform 0.18s ease;
flex-shrink: 0;
}
.sectionChevronOpen {
transform: rotate(180deg);
}
.sectionTitleRow {
display: flex;
align-items: flex-start;
gap: 10px;
}
.sectionTitleRow svg {
margin-top: 2px;
color: var(--primary);
flex-shrink: 0;
min-width: 0;
}
.sectionTitle {
display: block;
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
@@ -40,11 +65,32 @@
}
.sectionSubtitle {
display: block;
margin: 0;
font-size: 13px;
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 {
font-weight: 600;
}
+373 -34
View File
@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { 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 { EmptyTableRow } from '../components/EmptyState'
import { Modal } from '../components/Modal'
@@ -8,19 +17,29 @@ import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
import {
createInvoiceAccountTemplate,
createInvoiceItemTemplate,
deleteInvoiceAccountTemplate,
deleteInvoiceItemTemplate,
deleteInvoiceTemplate,
listInvoiceAccountTemplates,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateInvoiceAccountTemplate,
updateInvoiceItemTemplate,
} from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import type {
InvoiceAccountTemplate,
InvoiceItemTemplate,
InvoiceTemplate,
} from '../types/invoice'
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './SettingsPage.module.css'
type SectionId = 'templates' | 'items' | 'accounts'
type ItemDraft = {
title: string
duration: string
@@ -30,6 +49,13 @@ type ItemDraft = {
discountedPrice: string
}
type AccountDraft = {
bankName: string
accountHolderName: string
cardNumber: string
iban: string
}
const EMPTY_ITEM_DRAFT: ItemDraft = {
title: '',
duration: '',
@@ -39,6 +65,13 @@ const EMPTY_ITEM_DRAFT: ItemDraft = {
discountedPrice: '',
}
const EMPTY_ACCOUNT_DRAFT: AccountDraft = {
bankName: '',
accountHolderName: '',
cardNumber: '',
iban: '',
}
function itemDraftFromTemplate(t: InvoiceItemTemplate): ItemDraft {
return {
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() {
const navigate = useNavigate()
const { showToast } = useToast()
const [openSection, setOpenSection] = useState<SectionId | null>('templates')
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [accountTemplates, setAccountTemplates] = useState<InvoiceAccountTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -92,15 +198,30 @@ export function SettingsPage() {
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | null>(null)
const [accountEditorOpen, setAccountEditorOpen] = useState(false)
const [editingAccount, setEditingAccount] = useState<InvoiceAccountTemplate | null>(null)
const [accountDraft, setAccountDraft] = useState<AccountDraft>(EMPTY_ACCOUNT_DRAFT)
const [accountSubmitting, setAccountSubmitting] = useState(false)
const [accountFormError, setAccountFormError] = useState('')
const [removeAccountTarget, setRemoveAccountTarget] = useState<InvoiceAccountTemplate | null>(
null,
)
function toggleSection(id: SectionId) {
setOpenSection((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 (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
@@ -194,18 +370,14 @@ export function SettingsPage() {
{error ? <p className={styles.alertError}>{error}</p> : null}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitleRow}>
<FileStack size={18} />
<div>
<h3 className={styles.sectionTitle}>Invoice templates</h3>
<p className={styles.sectionSubtitle}>
Full blueprints (name, top text, items, key points, bank accounts) used when issuing
invoices to businesses. Pick one and edit, or start blank.
</p>
</div>
</div>
<CollapsibleSection
id="templates"
open={openSection === 'templates'}
onToggle={() => toggleSection('templates')}
icon={<FileStack size={18} />}
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={
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
@@ -214,8 +386,8 @@ export function SettingsPage() {
<Plus size={16} />
Add template
</button>
</div>
}
>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Templates</div>
@@ -250,7 +422,10 @@ export function SettingsPage() {
<td className={tableStyles.td}>
<div className={styles.itemTitle}>{template.name}</div>
{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)}
</div>
) : null}
@@ -285,19 +460,16 @@ export function SettingsPage() {
</tbody>
</table>
</div>
</section>
</CollapsibleSection>
<section className={`${styles.section} ${styles.sectionSpaced}`}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitleRow}>
<FileText size={18} />
<div>
<h3 className={styles.sectionTitle}>Invoice item templates</h3>
<p className={styles.sectionSubtitle}>
Reusable line items you can drop into invoice templates or when issuing an invoice.
</p>
</div>
</div>
<CollapsibleSection
id="items"
open={openSection === 'items'}
onToggle={() => toggleSection('items')}
icon={<FileText size={18} />}
title="Invoice item templates"
subtitle="Reusable line items you can drop into invoice templates or when issuing an invoice."
action={
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
@@ -306,8 +478,8 @@ export function SettingsPage() {
<Plus size={16} />
Add item
</button>
</div>
}
>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Predefined line items</div>
@@ -381,7 +553,91 @@ export function SettingsPage() {
</tbody>
</table>
</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}>
Tip: open a business from <Link to="/businesses">Businesses</Link> to issue an invoice from a
@@ -478,6 +734,77 @@ export function SettingsPage() {
</div>
</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
open={!!removeTplTarget}
title="Remove invoice template"
@@ -501,6 +828,18 @@ export function SettingsPage() {
onCancel={() => setRemoveItemTarget(null)}
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>
)
}
@@ -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<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) {
return apiRequest<InvoiceTemplatesListResponse>('/invoice-templates', {
auth: true,
+30
View File
@@ -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<CreateInvoiceItemTemplate
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 */
export type InvoiceTemplatesResponse = InvoiceItemTemplatesResponse
+21 -2
View File
@@ -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