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:
Alireza Hassani
2026-08-11 15:17:03 +03:30
co-authored by Cursor
parent 722933dda9
commit 917b840ee5
44 changed files with 5136 additions and 105 deletions
+260
View File
@@ -0,0 +1,260 @@
import { formatIrtInput, parseIrtInput } from './irtPrice'
import type { BusinessMessageKey } from '../i18n/messages'
import type {
InvoiceAccountInput,
InvoiceItemInput,
InvoiceItemTemplate,
InvoiceKeyPointInput,
InvoiceTemplate,
InvoiceTemplateItemInput,
} from '../types/invoice'
type Translate = (key: BusinessMessageKey, vars?: Record<string, string | number>) => string
const defaultTranslate: Translate = (key, vars) => {
const fallback: Record<string, string> = {
'invoiceDraft.error.itemTitle': 'Each item needs a title.',
'invoiceDraft.error.itemPrice': 'Price is required for "{title}".',
'invoiceDraft.error.itemDiscountInvalid': 'Discounted price is invalid for "{title}".',
'invoiceDraft.error.itemDiscountExceeds': 'Discounted price cannot exceed price for "{title}".',
}
let text = fallback[key] ?? key
if (vars) {
for (const [name, value] of Object.entries(vars)) {
text = text.replaceAll(`{${name}}`, String(value))
}
}
return text
}
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 draftsFromInvoice(invoice: {
name: string | null
topText: string | null
notes?: string | null
items?: Array<{
templateId: string | null
title: string
duration: string | null
worktime: string | null
description: string | null
price: number
discountedPrice: number | null
}>
keyPoints?: Array<{ text: string }>
accounts?: Array<{
bankName: string
accountHolderName: string | null
cardNumber: string | null
iban: string | null
}>
}): {
name: string
topText: string
notes: string
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
accounts: DraftAccount[]
} {
return {
name: invoice.name ?? '',
topText: invoice.topText ?? '',
notes: invoice.notes ?? '',
items:
(invoice.items?.length ?? 0) > 0
? invoice.items!.map((item) => ({
key: newKey(),
itemTemplateId: item.templateId ?? 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:
(invoice.keyPoints?.length ?? 0) > 0
? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text }))
: [],
accounts:
(invoice.accounts?.length ?? 0) > 0
? invoice.accounts!.map((acc) => ({
key: newKey(),
bankName: acc.bankName,
accountHolderName: acc.accountHolderName ?? '',
cardNumber: acc.cardNumber ?? '',
iban: acc.iban ?? '',
}))
: [],
}
}
export function buildLineItemsPayload(
items: DraftLineItem[],
t: Translate = defaultTranslate,
): InvoiceItemInput[] {
return items.map((item) => {
const title = item.title.trim()
const price = parseIrtInput(item.price)
if (!title) throw new Error(t('invoiceDraft.error.itemTitle'))
if (price === null) throw new Error(t('invoiceDraft.error.itemPrice', { title }))
const discountedPrice = item.discountedPrice.trim()
? parseIrtInput(item.discountedPrice)
: null
if (item.discountedPrice.trim() && discountedPrice === null) {
throw new Error(t('invoiceDraft.error.itemDiscountInvalid', { title }))
}
if (discountedPrice !== null && discountedPrice > price) {
throw new Error(t('invoiceDraft.error.itemDiscountExceeds', { 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[],
t: Translate = defaultTranslate,
): InvoiceTemplateItemInput[] {
return buildLineItemsPayload(items, t).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)
}
@@ -0,0 +1,45 @@
import type { DailyActivityPoint } from '../services/dailyActivityService'
import { buildLast12MonthKeys, formatMonthLabel } from './productActivity'
export interface MonthActivityPoint {
monthKey: string
label: string
primary: number
secondary: number
}
function toMonthKey(dateKey: string): string {
return dateKey.slice(0, 7)
}
/** Roll daily dual series into the last 12 calendar months. */
export function aggregateDailyActivityByMonth(
primaryItems: DailyActivityPoint[],
secondaryItems: DailyActivityPoint[],
locale: string,
): MonthActivityPoint[] {
const monthKeys = buildLast12MonthKeys()
const primaryByMonth = new Map(monthKeys.map((key) => [key, 0]))
const secondaryByMonth = new Map(monthKeys.map((key) => [key, 0]))
for (const item of primaryItems) {
const key = toMonthKey(item.date)
if (primaryByMonth.has(key)) {
primaryByMonth.set(key, (primaryByMonth.get(key) ?? 0) + item.count)
}
}
for (const item of secondaryItems) {
const key = toMonthKey(item.date)
if (secondaryByMonth.has(key)) {
secondaryByMonth.set(key, (secondaryByMonth.get(key) ?? 0) + item.count)
}
}
return monthKeys.map((monthKey) => ({
monthKey,
label: formatMonthLabel(monthKey, locale),
primary: primaryByMonth.get(monthKey) ?? 0,
secondary: secondaryByMonth.get(monthKey) ?? 0,
}))
}
+9 -4
View File
@@ -14,12 +14,17 @@ function toMonthKey(iso: string): string {
return `${year}-${month}`
}
function formatMonthLabel(monthKey: string, locale: string): string {
export function formatMonthLabel(monthKey: string, locale: string): string {
const [year, month] = monthKey.split('-').map(Number)
return new Date(year, month - 1, 1).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
const date = new Date(year, month - 1, 1)
if (locale === 'fa') {
return date.toLocaleString('fa-IR', {
month: 'long',
calendar: 'persian',
})
}
return date.toLocaleString('en-US', {
month: 'short',
calendar: 'gregory',
numberingSystem: 'latn',
})
}