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
+170
View File
@@ -0,0 +1,170 @@
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import type {
InvoiceAccountInput,
InvoiceItemInput,
InvoiceItemTemplate,
InvoiceKeyPointInput,
InvoiceTemplate,
InvoiceTemplateItemInput,
} from '../types/invoice'
export type DraftLineItem = {
key: string
itemTemplateId?: string
title: string
duration: string
worktime: string
description: string
price: string
discountedPrice: string
}
export type DraftKeyPoint = { key: string; text: string }
export type DraftAccount = {
key: string
bankName: string
accountHolderName: string
cardNumber: string
iban: string
}
export function newKey() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function emptyDraftItem(): DraftLineItem {
return {
key: newKey(),
title: '',
duration: '',
worktime: '',
description: '',
price: '',
discountedPrice: '',
}
}
export function emptyDraftKeyPoint(): DraftKeyPoint {
return { key: newKey(), text: '' }
}
export function emptyDraftAccount(): DraftAccount {
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
}
export function draftItemFromItemTemplate(template: InvoiceItemTemplate): DraftLineItem {
return {
key: newKey(),
itemTemplateId: template.id,
title: template.title,
duration: template.duration ?? '',
worktime: template.worktime ?? '',
description: template.description ?? '',
price: formatIrtInput(String(Math.round(template.price))),
discountedPrice:
template.discountedPrice === null || template.discountedPrice === undefined
? ''
: formatIrtInput(String(Math.round(template.discountedPrice))),
}
}
export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
name: string
topText: string
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
accounts: DraftAccount[]
} {
return {
name: template.name,
topText: template.topText ?? '',
items:
template.items.length > 0
? template.items.map((item) => ({
key: newKey(),
itemTemplateId: item.itemTemplateId ?? undefined,
title: item.title,
duration: item.duration ?? '',
worktime: item.worktime ?? '',
description: item.description ?? '',
price: formatIrtInput(String(Math.round(item.price))),
discountedPrice:
item.discountedPrice === null || item.discountedPrice === undefined
? ''
: formatIrtInput(String(Math.round(item.discountedPrice))),
}))
: [emptyDraftItem()],
keyPoints:
template.keyPoints.length > 0
? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text }))
: [],
accounts:
template.accounts.length > 0
? template.accounts.map((acc) => ({
key: newKey(),
bankName: acc.bankName,
accountHolderName: acc.accountHolderName ?? '',
cardNumber: acc.cardNumber ?? '',
iban: acc.iban ?? '',
}))
: [],
}
}
export function buildLineItemsPayload(items: DraftLineItem[]): InvoiceItemInput[] {
return items.map((item) => {
const title = item.title.trim()
const price = parseIrtInput(item.price)
if (!title) throw new Error('Each item needs a title.')
if (price === null) throw new Error(`Price is required for “${title}”.`)
const discountedPrice = item.discountedPrice.trim()
? parseIrtInput(item.discountedPrice)
: null
if (item.discountedPrice.trim() && discountedPrice === null) {
throw new Error(`Discounted price is invalid for “${title}”.`)
}
if (discountedPrice !== null && discountedPrice > price) {
throw new Error(`Discounted price cannot exceed price for “${title}”.`)
}
return {
templateId: item.itemTemplateId,
title,
duration: item.duration.trim() || undefined,
worktime: item.worktime.trim() || undefined,
description: item.description.trim() || undefined,
price,
discountedPrice,
}
})
}
export function buildTemplateItemsPayload(items: DraftLineItem[]): InvoiceTemplateItemInput[] {
return buildLineItemsPayload(items).map((item) => ({
itemTemplateId: item.templateId,
title: item.title,
duration: item.duration,
worktime: item.worktime,
description: item.description,
price: item.price,
discountedPrice: item.discountedPrice,
}))
}
export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointInput[] {
return points
.map((p) => p.text.trim())
.filter(Boolean)
.map((text) => ({ text }))
}
export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] {
return accounts
.map((acc) => ({
bankName: acc.bankName.trim(),
accountHolderName: acc.accountHolderName.trim() || undefined,
cardNumber: acc.cardNumber.trim() || undefined,
iban: acc.iban.trim() || undefined,
}))
.filter((acc) => acc.bankName)
}