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,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,
|
||||
}))
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user