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:
co-authored by
Cursor
parent
8f4af7a16c
commit
cbffb23ec3
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: calc(100vh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
@@ -35,12 +38,14 @@
|
||||
position: relative;
|
||||
padding: 18px 18px 10px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
@@ -64,5 +69,8 @@
|
||||
|
||||
.body {
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
.wrapper {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.wrapper:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 88px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor ul,
|
||||
.editor ol {
|
||||
padding-left: 1.4em;
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
|
||||
.editor p {
|
||||
margin: 0 0 0.35em;
|
||||
}
|
||||
|
||||
.editor p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Bold, Italic, List, ListOrdered, Underline } from 'lucide-react'
|
||||
import styles from './RichTextEditor.module.css'
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
/** Compact editor height for short fields like invoice top text. */
|
||||
editorMinHeight?: number
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
editorMinHeight = 88,
|
||||
}: RichTextEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const syncChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||
editorRef.current.innerHTML = value
|
||||
}
|
||||
}, [value])
|
||||
|
||||
function exec(command: string) {
|
||||
editorRef.current?.focus()
|
||||
document.execCommand(command)
|
||||
syncChange()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
|
||||
<Bold size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('underline')}
|
||||
title="Underline"
|
||||
aria-label="Underline"
|
||||
>
|
||||
<Underline size={14} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('insertUnorderedList')}
|
||||
title="Bullet list"
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('insertOrderedList')}
|
||||
title="Numbered list"
|
||||
aria-label="Numbered list"
|
||||
>
|
||||
<ListOrdered size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={styles.editor}
|
||||
style={{ minHeight: editorMinHeight }}
|
||||
contentEditable
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder={placeholder}
|
||||
onInput={syncChange}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** True when HTML is empty or only whitespace / empty tags. */
|
||||
export function isEmptyRichText(html: string | null | undefined): boolean {
|
||||
return richTextToPlain(html).length === 0
|
||||
}
|
||||
|
||||
/** Strip tags for single-line list previews. */
|
||||
export function richTextToPlain(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
Reference in New Issue
Block a user