Ship invoice templates, public viewer, and print-ready show page.

Move template/issue editors to full pages, add public invoice SPA with PDF print, and local-aware public URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-26 11:04:29 +03:30
co-authored by Cursor
parent 8f4af7a16c
commit cbffb23ec3
20 changed files with 2501 additions and 564 deletions
@@ -0,0 +1,314 @@
import { Pencil, Plus, X } from 'lucide-react'
import type { InvoiceItemTemplate } from '../types/invoice'
import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice'
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
import {
draftItemFromItemTemplate,
emptyDraftAccount,
emptyDraftItem,
emptyDraftKeyPoint,
} from '../utils/invoiceDraft'
import tableStyles from '../pages/BusinessesPage.module.css'
import styles from '../pages/BusinessInvoicesPage.module.css'
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
}
export function InvoiceDraftFields({
itemTemplates,
items,
keyPoints,
accounts,
selectedItemTemplateId,
onSelectedItemTemplateId,
onItemsChange,
onKeyPointsChange,
onAccountsChange,
showItemTemplatePicker = 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('')
}
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}`}>
<label>Point {index + 1}</label>
<input
value={point.text}
onChange={(e) =>
onKeyPointsChange(
keyPoints.map((p) =>
p.key === point.key ? { ...p, text: e.target.value } : p,
),
)
}
placeholder="e.g. Payment due within 7 days"
/>
</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}>Account numbers</h4>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
>
<Plus size={16} />
Add account
</button>
</div>
{accounts.length === 0 ? (
<p className={styles.templateHint}>No bank accounts yet.</p>
) : (
<div className={styles.repeatStack}>
{accounts.map((acc) => (
<div key={acc.key} className={styles.accountRow}>
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
<label>Bank name</label>
<input
value={acc.bankName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
),
)
}
placeholder="Bank name"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
<label>Account holder</label>
<input
value={acc.accountHolderName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
),
)
}
placeholder="Account holder name"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
<label>Card number</label>
<input
value={acc.cardNumber}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
),
)
}
placeholder="Optional"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
<label>IBAN</label>
<input
value={acc.iban}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, iban: e.target.value } : a,
),
)
}
placeholder="Optional"
/>
</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>
</>
)
}