Add business Finance hub with invoices and templates.
Ship invoice issue/list/templates under Finance, fix tenant theme reset and owner role badge, and show customers-joined charts by Persian months. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
722933dda9
commit
917b840ee5
@@ -0,0 +1,351 @@
|
||||
import { Pencil, Plus, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||
import { formatIrtInput } from '../utils/irtPrice'
|
||||
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
|
||||
import {
|
||||
draftItemFromItemTemplate,
|
||||
emptyDraftAccount,
|
||||
emptyDraftItem,
|
||||
emptyDraftKeyPoint,
|
||||
} 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()
|
||||
}
|
||||
|
||||
type Props = {
|
||||
itemTemplates: InvoiceItemTemplate[]
|
||||
items: DraftLineItem[]
|
||||
keyPoints: DraftKeyPoint[]
|
||||
accounts: DraftAccount[]
|
||||
selectedItemTemplateId: string
|
||||
onSelectedItemTemplateId: (id: string) => void
|
||||
onItemsChange: (items: DraftLineItem[]) => void
|
||||
onKeyPointsChange: (points: DraftKeyPoint[]) => void
|
||||
onAccountsChange: (accounts: DraftAccount[]) => void
|
||||
showItemTemplatePicker?: boolean
|
||||
/** Rendered after line items (before key points / accounts), e.g. totals row. */
|
||||
afterItems?: ReactNode
|
||||
}
|
||||
|
||||
export function InvoiceDraftFields({
|
||||
itemTemplates,
|
||||
items,
|
||||
keyPoints,
|
||||
accounts,
|
||||
selectedItemTemplateId,
|
||||
onSelectedItemTemplateId,
|
||||
onItemsChange,
|
||||
onKeyPointsChange,
|
||||
onAccountsChange,
|
||||
showItemTemplatePicker = 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('')
|
||||
}
|
||||
|
||||
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.accounts')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('invoiceDraft.addAccount')}
|
||||
</button>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user