Files
dashboards/apps/business/src/components/InvoiceDraftFields.tsx
T

470 lines
18 KiB
TypeScript

import { Pencil, Plus, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { useT } from '../i18n/useT'
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,
emptyDraftKeyPoint,
emptyDraftPaymentMethod,
} from '../utils/invoiceDraft'
import formStyles from './InvoiceForm.module.css'
import styles from './InvoiceDraftFields.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
/** 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()
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((entry) => entry.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((entry) => entry.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>{t('invoiceDraft.itemLabel', { index: index + 1 })}</span>
<button
type="button"
className={styles.removeItemBtn}
onClick={() => removeItem(item.key)}
disabled={items.length <= 1}
aria-label={t('invoiceDraft.removeItem')}
title={t('invoiceDraft.removeItem')}
>
<X size={16} />
</button>
</div>
<div className={styles.itemGrid}>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldTitle')}</label>
<input
value={item.title}
onChange={(e) => updateItem(item.key, { title: e.target.value })}
placeholder={t('invoiceDraft.fieldTitlePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldDuration')}</label>
<input
value={item.duration}
onChange={(e) => updateItem(item.key, { duration: e.target.value })}
placeholder={t('invoiceDraft.fieldDurationPlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldWorktime')}</label>
<input
value={item.worktime}
onChange={(e) => updateItem(item.key, { worktime: e.target.value })}
placeholder={t('invoiceDraft.fieldWorktimePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldPrice')}</label>
<input
inputMode="numeric"
value={item.price}
onChange={(e) => updateItem(item.key, { price: formatIrtInput(e.target.value) })}
placeholder="0"
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldDiscountedPrice')}</label>
<input
inputMode="numeric"
value={item.discountedPrice}
onChange={(e) =>
updateItem(item.key, { discountedPrice: formatIrtInput(e.target.value) })
}
placeholder={t('invoiceDraft.fieldDiscountedPricePlaceholder')}
/>
</div>
<div className={`${formStyles.field} ${styles.descField}`}>
<label>{t('invoiceDraft.fieldDescription')}</label>
<textarea
rows={2}
value={item.description}
onChange={(e) => updateItem(item.key, { description: e.target.value })}
placeholder={t('invoiceDraft.fieldDescriptionPlaceholder')}
/>
</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={t('invoiceDraft.addFromTemplateAria')}
>
<option value="">{t('invoiceDraft.addFromTemplate')}</option>
{itemTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.title}
</option>
))}
</select>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
onClick={addFromItemTemplate}
disabled={!selectedItemTemplateId}
>
<Plus size={14} />
{t('invoiceDraft.add')}
</button>
</div>
) : (
<div />
)}
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
onClick={() => onItemsChange([...items, emptyDraftItem()])}
>
<Pencil size={14} />
{t('invoiceDraft.addCustomItem')}
</button>
</div>
{afterItems}
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.keyPoints')}</h4>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])}
>
<Plus size={16} />
{t('invoiceDraft.addPoint')}
</button>
</div>
{keyPoints.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noKeyPoints')}</p>
) : (
<div className={styles.repeatStack}>
{keyPoints.map((point, index) => (
<div key={point.key} className={styles.repeatRow}>
<div className={`${formStyles.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={t('invoiceDraft.keyPointPlaceholder')}
aria-label={t('invoiceDraft.keyPointAria', { index: index + 1 })}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() => onKeyPointsChange(keyPoints.filter((p) => p.key !== point.key))}
aria-label={t('invoiceDraft.removeKeyPoint')}
title={t('invoiceDraft.removeKeyPoint')}
>
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.paymentMethods')}</h4>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
>
<Plus size={16} />
{t('invoiceDraft.addPaymentMethod')}
</button>
</div>
{paymentMethods.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noPaymentMethods')}</p>
) : (
<div className={styles.repeatStack}>
{paymentMethods.map((method, index) => (
<div key={method.key} className={styles.repeatRow}>
<div className={`${formStyles.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={t('invoiceDraft.paymentMethodPlaceholder')}
aria-label={t('invoiceDraft.paymentMethodAria', { index: index + 1 })}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() =>
onPaymentMethodsChange(paymentMethods.filter((m) => m.key !== method.key))
}
aria-label={t('invoiceDraft.removePaymentMethod')}
title={t('invoiceDraft.removePaymentMethod')}
>
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</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={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>
{accounts.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</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={`${formStyles.field} ${styles.accountCol2}`}>
{showLabels ? <label>{t('invoiceDraft.bankName')}</label> : null}
<input
value={acc.bankName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.bankName')}
aria-label={t('invoiceDraft.bankName')}
/>
</div>
<div className={`${formStyles.field} ${styles.accountCol2}`}>
{showLabels ? <label>{t('invoiceDraft.accountHolder')}</label> : null}
<input
value={acc.accountHolderName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.accountHolder')}
aria-label={t('invoiceDraft.accountHolder')}
/>
</div>
<div className={`${formStyles.field} ${styles.accountCol3}`}>
{showLabels ? <label>{t('invoiceDraft.cardNumber')}</label> : null}
<input
value={acc.cardNumber}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.optional')}
aria-label={t('invoiceDraft.cardNumber')}
/>
</div>
<div className={`${formStyles.field} ${styles.accountCol5}`}>
{showLabels ? <label>{t('invoiceDraft.iban')}</label> : null}
<input
value={acc.iban}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, iban: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.optional')}
aria-label={t('invoiceDraft.iban')}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
aria-label={t('invoiceDraft.removeAccount')}
title={t('invoiceDraft.removeAccount')}
>
<X size={16} />
</button>
</div>
)
})}
</div>
)}
</div>
</>
)
}