461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
import { Pencil, Plus, X } from 'lucide-react'
|
|
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,
|
|
emptyDraftKeyPoint,
|
|
emptyDraftPaymentMethod,
|
|
} from '../utils/invoiceDraft'
|
|
import tableStyles from '../pages/BusinessesPage.module.css'
|
|
import styles from '../pages/BusinessInvoicesPage.module.css'
|
|
|
|
function focusKeyPointInput(index: number) {
|
|
const el = document.querySelector<HTMLInputElement>(`input[data-keypoint-index="${index}"]`)
|
|
el?.focus()
|
|
el?.select()
|
|
}
|
|
|
|
function focusPaymentMethodInput(index: number) {
|
|
const el = document.querySelector<HTMLInputElement>(`input[data-payment-method-index="${index}"]`)
|
|
el?.focus()
|
|
el?.select()
|
|
}
|
|
|
|
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)))
|
|
}
|
|
|
|
function removeItem(key: string) {
|
|
if (items.length <= 1) return
|
|
onItemsChange(items.filter((item) => item.key !== key))
|
|
}
|
|
|
|
function addFromItemTemplate() {
|
|
const template = itemTemplates.find((t) => t.id === selectedItemTemplateId)
|
|
if (!template) return
|
|
const onlyEmpty =
|
|
items.length === 1 &&
|
|
!items[0].title.trim() &&
|
|
!items[0].price.trim() &&
|
|
!items[0].description.trim()
|
|
onItemsChange(onlyEmpty ? [draftItemFromItemTemplate(template)] : [...items, draftItemFromItemTemplate(template)])
|
|
onSelectedItemTemplateId('')
|
|
}
|
|
|
|
function addFromAccountTemplate() {
|
|
const template = accountTemplates.find((t) => t.id === selectedAccountTemplateId)
|
|
if (!template) return
|
|
onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)])
|
|
onSelectedAccountTemplateId('')
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className={styles.itemsStack}>
|
|
{items.map((item, index) => (
|
|
<div key={item.key} className={styles.itemCard}>
|
|
<div className={styles.itemCardHeader}>
|
|
<span>Item {index + 1}</span>
|
|
<button
|
|
type="button"
|
|
className={styles.removeItemBtn}
|
|
onClick={() => removeItem(item.key)}
|
|
disabled={items.length <= 1}
|
|
aria-label="Remove item"
|
|
title="Remove item"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<div className={styles.itemGrid}>
|
|
<div className={tableStyles.field}>
|
|
<label>Title</label>
|
|
<input
|
|
value={item.title}
|
|
onChange={(e) => updateItem(item.key, { title: e.target.value })}
|
|
placeholder="Service title"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Duration</label>
|
|
<input
|
|
value={item.duration}
|
|
onChange={(e) => updateItem(item.key, { duration: e.target.value })}
|
|
placeholder="e.g. 3 months"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Worktime</label>
|
|
<input
|
|
value={item.worktime}
|
|
onChange={(e) => updateItem(item.key, { worktime: e.target.value })}
|
|
placeholder="e.g. 40 hours"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Price (IRT)</label>
|
|
<input
|
|
inputMode="numeric"
|
|
value={item.price}
|
|
onChange={(e) => updateItem(item.key, { price: formatIrtInput(e.target.value) })}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label>Discounted price (IRT)</label>
|
|
<input
|
|
inputMode="numeric"
|
|
value={item.discountedPrice}
|
|
onChange={(e) =>
|
|
updateItem(item.key, { discountedPrice: formatIrtInput(e.target.value) })
|
|
}
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${styles.descField}`}>
|
|
<label>Description</label>
|
|
<textarea
|
|
rows={2}
|
|
value={item.description}
|
|
onChange={(e) => updateItem(item.key, { description: e.target.value })}
|
|
placeholder="Optional details"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className={styles.itemActions}>
|
|
{showItemTemplatePicker ? (
|
|
<div className={styles.itemActionsLeft}>
|
|
<select
|
|
id="item-template-pick"
|
|
className={styles.compactSelect}
|
|
value={selectedItemTemplateId}
|
|
onChange={(e) => onSelectedItemTemplateId(e.target.value)}
|
|
aria-label="Add from item template"
|
|
>
|
|
<option value="">Add from template…</option>
|
|
{itemTemplates.map((template) => (
|
|
<option key={template.id} value={template.id}>
|
|
{template.title} · {formatIrtPrice(template.price)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost} ${styles.compactBtn}`}
|
|
onClick={addFromItemTemplate}
|
|
disabled={!selectedItemTemplateId}
|
|
>
|
|
<Plus size={14} />
|
|
Add
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost} ${styles.compactBtn}`}
|
|
onClick={() => onItemsChange([...items, emptyDraftItem()])}
|
|
>
|
|
<Pencil size={14} />
|
|
Add custom item
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.blockSection}>
|
|
<div className={styles.blockHeader}>
|
|
<h4 className={styles.blockTitle}>Key points</h4>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])}
|
|
>
|
|
<Plus size={16} />
|
|
Add point
|
|
</button>
|
|
</div>
|
|
{keyPoints.length === 0 ? (
|
|
<p className={styles.templateHint}>No key points yet.</p>
|
|
) : (
|
|
<div className={styles.repeatStack}>
|
|
{keyPoints.map((point, index) => (
|
|
<div key={point.key} className={styles.repeatRow}>
|
|
<div className={`${tableStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
|
|
<input
|
|
data-keypoint-index={index}
|
|
value={point.text}
|
|
onChange={(e) =>
|
|
onKeyPointsChange(
|
|
keyPoints.map((p) =>
|
|
p.key === point.key ? { ...p, text: e.target.value } : p,
|
|
),
|
|
)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== 'Enter') return
|
|
e.preventDefault()
|
|
const next = index + 1
|
|
if (next < keyPoints.length) {
|
|
focusKeyPointInput(next)
|
|
return
|
|
}
|
|
onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])
|
|
window.setTimeout(() => focusKeyPointInput(next), 0)
|
|
}}
|
|
placeholder="e.g. Payment due within 7 days"
|
|
aria-label={`Key point ${index + 1}`}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={styles.removeFieldBtn}
|
|
onClick={() => onKeyPointsChange(keyPoints.filter((p) => p.key !== point.key))}
|
|
aria-label="Remove key point"
|
|
title="Remove"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.blockSection}>
|
|
<div className={styles.blockHeader}>
|
|
<h4 className={styles.blockTitle}>Payment methods</h4>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
|
|
>
|
|
<Plus size={16} />
|
|
Add payment method
|
|
</button>
|
|
</div>
|
|
{paymentMethods.length === 0 ? (
|
|
<p className={styles.templateHint}>No payment methods yet.</p>
|
|
) : (
|
|
<div className={styles.repeatStack}>
|
|
{paymentMethods.map((method, index) => (
|
|
<div key={method.key} className={styles.repeatRow}>
|
|
<div className={`${tableStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
|
|
<input
|
|
data-payment-method-index={index}
|
|
value={method.text}
|
|
onChange={(e) =>
|
|
onPaymentMethodsChange(
|
|
paymentMethods.map((m) =>
|
|
m.key === method.key ? { ...m, text: e.target.value } : m,
|
|
),
|
|
)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== 'Enter') return
|
|
e.preventDefault()
|
|
const next = index + 1
|
|
if (next < paymentMethods.length) {
|
|
focusPaymentMethodInput(next)
|
|
return
|
|
}
|
|
onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])
|
|
window.setTimeout(() => focusPaymentMethodInput(next), 0)
|
|
}}
|
|
placeholder="e.g. Cash, card transfer, or installments"
|
|
aria-label={`Payment method ${index + 1}`}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={styles.removeFieldBtn}
|
|
onClick={() =>
|
|
onPaymentMethodsChange(paymentMethods.filter((m) => m.key !== method.key))
|
|
}
|
|
aria-label="Remove payment method"
|
|
title="Remove"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.blockSection}>
|
|
<div className={styles.blockHeader}>
|
|
<h4 className={styles.blockTitle}>Account numbers</h4>
|
|
<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>
|
|
) : (
|
|
<div className={styles.repeatStack}>
|
|
{accounts.map((acc, index) => {
|
|
const showLabels = index === 0
|
|
return (
|
|
<div
|
|
key={acc.key}
|
|
className={`${styles.accountRow} ${showLabels ? '' : styles.accountRowPlain}`}
|
|
>
|
|
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
|
{showLabels ? <label>Bank name</label> : null}
|
|
<input
|
|
value={acc.bankName}
|
|
onChange={(e) =>
|
|
onAccountsChange(
|
|
accounts.map((a) =>
|
|
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
|
|
),
|
|
)
|
|
}
|
|
placeholder="Bank name"
|
|
aria-label="Bank name"
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
|
{showLabels ? <label>Account holder</label> : null}
|
|
<input
|
|
value={acc.accountHolderName}
|
|
onChange={(e) =>
|
|
onAccountsChange(
|
|
accounts.map((a) =>
|
|
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
|
|
),
|
|
)
|
|
}
|
|
placeholder="Account holder name"
|
|
aria-label="Account holder"
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
|
|
{showLabels ? <label>Card number</label> : null}
|
|
<input
|
|
value={acc.cardNumber}
|
|
onChange={(e) =>
|
|
onAccountsChange(
|
|
accounts.map((a) =>
|
|
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
|
|
),
|
|
)
|
|
}
|
|
placeholder="Optional"
|
|
aria-label="Card number"
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
|
|
{showLabels ? <label>IBAN</label> : null}
|
|
<input
|
|
value={acc.iban}
|
|
onChange={(e) =>
|
|
onAccountsChange(
|
|
accounts.map((a) =>
|
|
a.key === acc.key ? { ...a, iban: e.target.value } : a,
|
|
),
|
|
)
|
|
}
|
|
placeholder="Optional"
|
|
aria-label="IBAN"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={styles.removeFieldBtn}
|
|
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
|
|
aria-label="Remove account"
|
|
title="Remove"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|