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
+14
View File
@@ -23,6 +23,12 @@ import { StorePage } from './pages/StorePage'
import { StoreItemsPage } from './pages/StoreItemsPage'
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
import { CustomersPage } from './pages/CustomersPage'
import { InvoicesPage } from './pages/InvoicesPage'
import { IssueInvoicePage } from './pages/IssueInvoicePage'
import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage'
import { InvoiceTemplateEditorPage } from './pages/InvoiceTemplateEditorPage'
import { TransactionsPage } from './pages/TransactionsPage'
import { FinancePage } from './pages/FinancePage'
import { CustomerProductsPage } from './pages/CustomerProductsPage'
import { CustomerProductDetailsPage } from './pages/CustomerProductDetailsPage'
import { AddCustomerProductPage } from './pages/AddCustomerProductPage'
@@ -86,6 +92,14 @@ function App() {
<Route path="store/cards" element={<ShoppingCardsPage />} />
<Route path="store/settings" element={<StoreSettingsPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="finance" element={<FinancePage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="invoices/new" element={<IssueInvoicePage />} />
<Route path="invoices/templates" element={<InvoiceTemplatesPage />} />
<Route path="invoices/templates/new" element={<InvoiceTemplateEditorPage />} />
<Route path="invoices/templates/:templateId" element={<InvoiceTemplateEditorPage />} />
<Route path="invoices/:invoiceId/edit" element={<IssueInvoicePage />} />
<Route path="transactions" element={<TransactionsPage />} />
<Route path="customer-products" element={<CustomerProductsPage />} />
<Route path="customer-products/new" element={<AddCustomerProductPage />} />
<Route path="customer-products/:id/edit" element={<AddCustomerProductPage />} />
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import { searchCustomers, type BusinessCustomer } from '../services/customerService'
import styles from './CategorySearchSelect.module.css'
interface CustomerSearchSelectProps {
value: BusinessCustomer | null
onChange: (customer: BusinessCustomer | null) => void
disabled?: boolean
placeholder?: string
id?: string
}
function customerLabel(customer: BusinessCustomer, withPhone = false) {
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
const phone = formatCellForDisplay(customer.cellNumber)
if (withPhone && name && phone) {
return `${name} · ${phone}`
}
return name || phone
}
export function CustomerSearchSelect({
value,
onChange,
disabled = false,
placeholder,
id,
}: CustomerSearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<BusinessCustomer[]>([])
const [loading, setLoading] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const selectedLabel = value ? customerLabel(value, true) : ''
const searchPlaceholder = placeholder ?? t('issueInvoice.pickCustomerPlaceholder')
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
useEffect(() => {
if (disabled) {
setOpen(false)
setQuery('')
}
}, [disabled])
useEffect(() => {
if (!open) return
const q = query.trim()
if (q.length < 2) {
setResults([])
setLoading(false)
return
}
const controller = new AbortController()
setLoading(true)
const timer = window.setTimeout(async () => {
try {
const res = await searchCustomers(q, 20, controller.signal)
setResults(res.items)
} catch (err) {
if (isAbortError(err)) return
setResults([])
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}, 300)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [query, open])
function selectOption(customer: BusinessCustomer) {
onChange(customer)
setOpen(false)
setQuery('')
}
return (
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
<div
className={[
styles.inputWrap,
open ? styles.inputWrapOpen : '',
disabled ? styles.inputWrapDisabled : '',
]
.filter(Boolean)
.join(' ')}
>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={value ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange(null)}
aria-label={t('issueInvoice.changeCustomer')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={styles.dropdown} role="listbox">
{query.trim().length < 2 ? (
<li className={styles.noResults}>{t('issueInvoice.pickCustomerHint')}</li>
) : loading ? (
<li className={styles.noResults}>{t('issueInvoice.loading')}</li>
) : results.length === 0 ? (
<li className={styles.noResults}>{t('issueInvoice.pickCustomerNoResults')}</li>
) : (
results.map((customer) => (
<li key={customer.id}>
<button
type="button"
className={[
styles.option,
value?.id === customer.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => selectOption(customer)}
role="option"
aria-selected={value?.id === customer.id}
>
<span className={styles.optionLabel}>{customerLabel(customer)}</span>
<span className={styles.optionSecondary} dir="ltr">
{formatCellForDisplay(customer.cellNumber)}
</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
)
}
@@ -94,6 +94,10 @@
gap: 8px;
}
.chartMonth .group {
min-width: 36px;
}
.bars {
display: flex;
align-items: flex-end;
@@ -107,6 +111,10 @@
transition: height 0.3s ease;
}
.barMonth {
width: 10px;
}
.barPrimary {
background: linear-gradient(
180deg,
@@ -127,8 +135,9 @@
font-size: 10px;
color: var(--text-muted);
font-weight: 500;
font-family: var(--font-en), var(--font-ui), sans-serif;
font-family: var(--font-ui);
line-height: 1;
text-align: center;
}
@media (max-width: 768px) {
@@ -5,6 +5,7 @@ import type {
DailyActivityPoint,
DualDailyActivityResponse,
} from '../services/dailyActivityService'
import { aggregateDailyActivityByMonth } from '../utils/monthlyActivity'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from './DailyActivityChart.module.css'
@@ -22,6 +23,8 @@ interface DailyActivityChartProps {
primaryBarTitleKey: BusinessMessageKey
secondaryBarTitleKey: BusinessMessageKey
load: (signal: AbortSignal) => Promise<DualDailyActivityResponse>
/** Day bars (default) or roll up into last 12 months. */
granularity?: 'day' | 'month'
}
function formatDayLabel(dateKey: string, locale: string): string {
@@ -43,6 +46,7 @@ export function DailyActivityChart({
primaryBarTitleKey,
secondaryBarTitleKey,
load,
granularity = 'day',
}: DailyActivityChartProps) {
const t = useT()
const { locale } = useLocale()
@@ -82,14 +86,27 @@ export function DailyActivityChart({
return () => controller.abort()
}, [load, t, errorKey])
const monthItems = useMemo(() => {
if (granularity !== 'month') return []
return aggregateDailyActivityByMonth(primaryItems, secondaryItems, locale)
}, [granularity, primaryItems, secondaryItems, locale])
const maxValue = useMemo(() => {
if (granularity === 'month') {
const peak = Math.max(
...monthItems.map((item) => item.primary),
...monthItems.map((item) => item.secondary),
0,
)
return peak > 0 ? peak : 1
}
const peak = Math.max(
...primaryItems.map((item) => item.count),
...secondaryItems.map((item) => item.count),
0,
)
return peak > 0 ? peak : 1
}, [primaryItems, secondaryItems])
}, [granularity, monthItems, primaryItems, secondaryItems])
return (
<section className={styles.card} aria-label={t(titleKey)}>
@@ -119,43 +136,83 @@ export function DailyActivityChart({
) : (
<div className={styles.chartWrap}>
<div
className={styles.chart}
className={`${styles.chart}${granularity === 'month' ? ` ${styles.chartMonth}` : ''}`}
style={{ height: CHART_HEIGHT + 28 }}
role="img"
aria-label={t(titleKey)}
>
{primaryItems.map((item, index) => {
const secondary = secondaryItems[index]
const secondaryCount = secondary?.count ?? 0
const primaryHeight = (item.count / maxValue) * CHART_HEIGHT
const secondaryHeight = (secondaryCount / maxValue) * CHART_HEIGHT
const label = formatDayLabel(item.date, locale)
{granularity === 'month'
? monthItems.map((item) => {
const primaryHeight = (item.primary / maxValue) * CHART_HEIGHT
const secondaryHeight = (item.secondary / maxValue) * CHART_HEIGHT
return (
<div key={item.monthKey} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barPrimary} ${styles.barMonth}`}
style={{
height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0),
}}
title={t(primaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.primary,
})}
/>
<div
className={`${styles.bar} ${styles.barAccent} ${styles.barMonth}`}
style={{
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
}}
title={t(secondaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.secondary,
})}
/>
</div>
<span
className={styles.label}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
{item.label}
</span>
</div>
)
})
: primaryItems.map((item, index) => {
const secondary = secondaryItems[index]
const secondaryCount = secondary?.count ?? 0
const primaryHeight = (item.count / maxValue) * CHART_HEIGHT
const secondaryHeight = (secondaryCount / maxValue) * CHART_HEIGHT
const label = formatDayLabel(item.date, locale)
return (
<div key={item.date} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barPrimary}`}
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
title={t(primaryBarTitleKey, { day: label, count: item.count })}
/>
<div
className={`${styles.bar} ${styles.barAccent}`}
style={{
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
}}
title={t(secondaryBarTitleKey, {
day: label,
count: secondaryCount,
})}
/>
</div>
<span className={styles.label} lang="en" dir="ltr">
{label}
</span>
</div>
)
})}
return (
<div key={item.date} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barPrimary}`}
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
title={t(primaryBarTitleKey, { day: label, count: item.count })}
/>
<div
className={`${styles.bar} ${styles.barAccent}`}
style={{
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
}}
title={t(secondaryBarTitleKey, {
day: label,
count: secondaryCount,
})}
/>
</div>
<span className={styles.label} lang="en" dir="ltr">
{label}
</span>
</div>
)
})}
</div>
</div>
)}
@@ -6,9 +6,9 @@
position: sticky;
top: 0;
z-index: 50;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: var(--header-bg);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
border-bottom: 1px solid var(--glass-border);
isolation: isolate;
transform: translateZ(0);
+10 -6
View File
@@ -46,11 +46,15 @@ export function Header() {
const membership =
user?.businesses.find((b) => String(b.id) === String(activeBusinessId)) ??
user?.businesses[0]
const adminBadgeLabel = isSuperAdmin
const roleBadgeLabel = isSuperAdmin
? t('role.superAdmin')
: membership?.teamRole === 'admin'
? t('role.admin')
: null
: membership?.isOwner
? t('role.owner')
: membership?.teamRole === 'admin'
? t('role.admin')
: membership?.teamRole
? t('role.staff')
: null
useEffect(() => {
if (!menuOpen) return
@@ -116,8 +120,8 @@ export function Header() {
>
<div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span>
{adminBadgeLabel ? (
<span className={styles.roleBadge}>{adminBadgeLabel}</span>
{roleBadgeLabel ? (
<span className={styles.roleBadge}>{roleBadgeLabel}</span>
) : null}
</div>
<ChevronDown
@@ -76,6 +76,7 @@ export function HomeChartSlot({ chartId }: HomeChartSlotProps) {
primaryBarTitleKey="home.chart.customersJoined.bar"
secondaryBarTitleKey="home.chart.customersJoined.activeBar"
load={loadCustomersYear}
granularity="month"
/>
)
case 'products_added_1y':
@@ -0,0 +1,250 @@
.itemsStack {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
max-height: min(48vh, 420px);
overflow: auto;
padding-right: 2px;
}
.itemCard {
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.45);
}
.itemCardHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
}
.removeItemBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.18);
flex-shrink: 0;
}
.removeItemBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.14);
}
.removeItemBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/** Matches compact field height; align with input (not label). */
.removeFieldBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--field-height);
height: var(--field-height);
min-width: var(--field-height);
border-radius: var(--radius-sm);
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.18);
flex-shrink: 0;
}
.removeFieldBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.14);
}
.itemGrid {
display: grid;
grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr);
gap: 10px;
align-items: end;
}
.descField {
grid-column: 1 / -1;
}
.itemActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.itemActionsLeft {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.compactSelect {
width: min(260px, 100%);
min-height: 32px;
padding: 6px var(--select-padding-end) 6px 10px;
font-size: 12px;
font-family: var(--font-ui);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 14px;
cursor: pointer;
}
:global([dir='rtl']) .compactSelect {
padding: 6px 10px 6px var(--select-padding-end);
background-position: left 8px center;
}
.compactBtn {
padding: 6px 10px;
font-size: 12px;
white-space: nowrap;
}
.blockSection {
margin: 16px 0;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.22);
}
.blockHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.blockTitle {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.templateHint {
margin: 0 0 12px;
font-size: 13px;
color: var(--text-secondary);
}
.repeatStack {
display: flex;
flex-direction: column;
gap: 10px;
}
.repeatRow {
display: flex;
align-items: center;
gap: 8px;
}
.repeatRow > div {
flex: 1;
min-width: 0;
}
.fieldNoLabel {
margin: 0;
}
.fieldNoLabel label {
display: none;
}
.flexGrow {
flex: 1;
min-width: 0;
}
.accountRow {
display: grid;
grid-template-columns: 2fr 2fr 3fr 5fr var(--field-height);
align-items: end;
gap: 8px;
}
.accountRowPlain {
align-items: center;
}
.accountCol2,
.accountCol3,
.accountCol5 {
min-width: 0;
}
.keyPointList {
margin: 0;
padding-inline-start: 18px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 900px) {
.itemGrid {
grid-template-columns: 1fr 1fr;
}
.descField {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
.itemGrid {
grid-template-columns: 1fr;
}
.descField {
grid-column: span 1;
}
.accountRow {
grid-template-columns: 1fr var(--field-height);
}
.accountCol2,
.accountCol3,
.accountCol5 {
grid-column: 1;
}
.itemActions {
flex-direction: column;
align-items: stretch;
}
.itemActionsLeft {
flex-direction: column;
align-items: stretch;
}
.compactSelect {
width: 100%;
}
}
@@ -0,0 +1,351 @@
import { Pencil, Plus, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { useT } from '../i18n/useT'
import type { InvoiceItemTemplate } from '../types/invoice'
import { formatIrtInput } from '../utils/irtPrice'
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
import {
draftItemFromItemTemplate,
emptyDraftAccount,
emptyDraftItem,
emptyDraftKeyPoint,
} from '../utils/invoiceDraft'
import formStyles from './InvoiceForm.module.css'
import styles from './InvoiceDraftFields.module.css'
function focusKeyPointInput(index: number) {
const el = document.querySelector<HTMLInputElement>(`input[data-keypoint-index="${index}"]`)
el?.focus()
el?.select()
}
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
/** Rendered after line items (before key points / accounts), e.g. totals row. */
afterItems?: ReactNode
}
export function InvoiceDraftFields({
itemTemplates,
items,
keyPoints,
accounts,
selectedItemTemplateId,
onSelectedItemTemplateId,
onItemsChange,
onKeyPointsChange,
onAccountsChange,
showItemTemplatePicker = true,
afterItems,
}: Props) {
const t = useT()
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((entry) => entry.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>{t('invoiceDraft.itemLabel', { index: index + 1 })}</span>
<button
type="button"
className={styles.removeItemBtn}
onClick={() => removeItem(item.key)}
disabled={items.length <= 1}
aria-label={t('invoiceDraft.removeItem')}
title={t('invoiceDraft.removeItem')}
>
<X size={16} />
</button>
</div>
<div className={styles.itemGrid}>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldTitle')}</label>
<input
value={item.title}
onChange={(e) => updateItem(item.key, { title: e.target.value })}
placeholder={t('invoiceDraft.fieldTitlePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldDuration')}</label>
<input
value={item.duration}
onChange={(e) => updateItem(item.key, { duration: e.target.value })}
placeholder={t('invoiceDraft.fieldDurationPlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldWorktime')}</label>
<input
value={item.worktime}
onChange={(e) => updateItem(item.key, { worktime: e.target.value })}
placeholder={t('invoiceDraft.fieldWorktimePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldPrice')}</label>
<input
inputMode="numeric"
value={item.price}
onChange={(e) => updateItem(item.key, { price: formatIrtInput(e.target.value) })}
placeholder="0"
/>
</div>
<div className={formStyles.field}>
<label>{t('invoiceDraft.fieldDiscountedPrice')}</label>
<input
inputMode="numeric"
value={item.discountedPrice}
onChange={(e) =>
updateItem(item.key, { discountedPrice: formatIrtInput(e.target.value) })
}
placeholder={t('invoiceDraft.fieldDiscountedPricePlaceholder')}
/>
</div>
<div className={`${formStyles.field} ${styles.descField}`}>
<label>{t('invoiceDraft.fieldDescription')}</label>
<textarea
rows={2}
value={item.description}
onChange={(e) => updateItem(item.key, { description: e.target.value })}
placeholder={t('invoiceDraft.fieldDescriptionPlaceholder')}
/>
</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={t('invoiceDraft.addFromTemplateAria')}
>
<option value="">{t('invoiceDraft.addFromTemplate')}</option>
{itemTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.title}
</option>
))}
</select>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
onClick={addFromItemTemplate}
disabled={!selectedItemTemplateId}
>
<Plus size={14} />
{t('invoiceDraft.add')}
</button>
</div>
) : (
<div />
)}
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
onClick={() => onItemsChange([...items, emptyDraftItem()])}
>
<Pencil size={14} />
{t('invoiceDraft.addCustomItem')}
</button>
</div>
{afterItems}
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.keyPoints')}</h4>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])}
>
<Plus size={16} />
{t('invoiceDraft.addPoint')}
</button>
</div>
{keyPoints.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noKeyPoints')}</p>
) : (
<div className={styles.repeatStack}>
{keyPoints.map((point, index) => (
<div key={point.key} className={styles.repeatRow}>
<div className={`${formStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
<input
data-keypoint-index={index}
value={point.text}
onChange={(e) =>
onKeyPointsChange(
keyPoints.map((p) =>
p.key === point.key ? { ...p, text: e.target.value } : p,
),
)
}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
e.preventDefault()
const next = index + 1
if (next < keyPoints.length) {
focusKeyPointInput(next)
return
}
onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])
window.setTimeout(() => focusKeyPointInput(next), 0)
}}
placeholder={t('invoiceDraft.keyPointPlaceholder')}
aria-label={t('invoiceDraft.keyPointAria', { index: index + 1 })}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() => onKeyPointsChange(keyPoints.filter((p) => p.key !== point.key))}
aria-label={t('invoiceDraft.removeKeyPoint')}
title={t('invoiceDraft.removeKeyPoint')}
>
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
>
<Plus size={16} />
{t('invoiceDraft.addAccount')}
</button>
</div>
{accounts.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p>
) : (
<div className={styles.repeatStack}>
{accounts.map((acc, index) => {
const showLabels = index === 0
return (
<div
key={acc.key}
className={`${styles.accountRow} ${showLabels ? '' : styles.accountRowPlain}`}
>
<div className={`${formStyles.field} ${styles.accountCol2}`}>
{showLabels ? <label>{t('invoiceDraft.bankName')}</label> : null}
<input
value={acc.bankName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.bankName')}
aria-label={t('invoiceDraft.bankName')}
/>
</div>
<div className={`${formStyles.field} ${styles.accountCol2}`}>
{showLabels ? <label>{t('invoiceDraft.accountHolder')}</label> : null}
<input
value={acc.accountHolderName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.accountHolder')}
aria-label={t('invoiceDraft.accountHolder')}
/>
</div>
<div className={`${formStyles.field} ${styles.accountCol3}`}>
{showLabels ? <label>{t('invoiceDraft.cardNumber')}</label> : null}
<input
value={acc.cardNumber}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.optional')}
aria-label={t('invoiceDraft.cardNumber')}
/>
</div>
<div className={`${formStyles.field} ${styles.accountCol5}`}>
{showLabels ? <label>{t('invoiceDraft.iban')}</label> : null}
<input
value={acc.iban}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, iban: e.target.value } : a,
),
)
}
placeholder={t('invoiceDraft.optional')}
aria-label={t('invoiceDraft.iban')}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
aria-label={t('invoiceDraft.removeAccount')}
title={t('invoiceDraft.removeAccount')}
>
<X size={16} />
</button>
</div>
)
})}
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,227 @@
/* Shared primitives (field/button/layout) reused across invoice pages & modals. */
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field textarea,
.field select {
width: 100%;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
font-family: var(--font-ui);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.field input,
.field select {
min-height: var(--field-height);
}
.field select {
appearance: none;
-webkit-appearance: none;
padding-right: var(--select-padding-end);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
:global([dir='rtl']) .field select {
padding-right: var(--field-padding-x);
padding-left: var(--select-padding-end);
background-position: left var(--select-arrow-offset) center;
}
.field textarea {
min-height: calc(var(--field-height) + 8px);
resize: vertical;
}
.field input:focus,
.field textarea:focus,
.field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.span2 {
grid-column: span 2;
}
.fullField {
margin-bottom: 12px;
}
.metaGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.metaGridTwo {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.formGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.actionsRow {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
.totalRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 14px 0 4px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.2);
}
.totalLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.totalValue {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
transition: transform 0.15s, box-shadow 0.2s, background 0.2s, color 0.2s;
white-space: nowrap;
}
.btnPrimary {
color: #fff;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.3);
}
.btnPrimary:hover:not(:disabled) {
transform: translateY(-1px);
}
.btnPrimary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btnGhost {
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.25);
}
.btnGhost:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.2);
color: var(--text-primary);
}
.btnGhost:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alertError {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
color: #b91c1c;
}
.backLink {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--primary);
text-decoration: none;
}
.backLink:hover {
text-decoration: underline;
}
.section {
padding: 16px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
}
.sectionSpaced {
margin-top: 16px;
}
@media (max-width: 900px) {
.metaGrid {
grid-template-columns: 1fr;
}
.metaGridTwo {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.formGrid {
grid-template-columns: 1fr;
}
.span2 {
grid-column: span 1;
}
}
@@ -47,6 +47,13 @@
gap: 6px;
}
/* Remaining columns after filters; buttons at inline-end (physical left in RTL) */
.filterActionsEnd {
grid-column: span 5;
justify-content: flex-end;
gap: 8px;
}
.filterSpacerCol4 {
grid-column: span 4;
min-width: 0;
@@ -207,7 +214,8 @@
justify-content: flex-end;
}
.filterActionsCol1 {
.filterActionsCol1,
.filterActionsEnd {
grid-column: span 12;
justify-content: flex-end;
}
@@ -0,0 +1,76 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
padding: 20px;
}
.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);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16);
overflow: hidden;
font-family: var(--font-ui);
}
.modalWide {
max-width: 640px;
}
.modalXl {
max-width: min(1180px, calc(100vw - 40px));
}
.header {
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-inline-end: 36px;
}
.closeBtn {
position: absolute;
top: 14px;
inset-inline-end: 14px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.body {
padding: 18px;
overflow: auto;
flex: 1;
min-height: 0;
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import styles from './Modal.module.css'
interface ModalProps {
open: boolean
title: string
children: React.ReactNode
onClose: () => void
closeLabel?: string
wide?: boolean
/** Extra-wide dialog for dense forms (e.g. invoice editor). */
xl?: boolean
}
export function Modal({ open, title, children, onClose, closeLabel = 'Close', wide, xl }: ModalProps) {
const { locale, dir } = useLocale()
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [open, onClose])
if (!open) return null
const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : ''
return createPortal(
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className={`${styles.modal} ${sizeClass}`}
onMouseDown={(e) => e.stopPropagation()}
lang={locale}
dir={dir}
>
<div className={styles.header}>
<h3 className={styles.title}>{title}</h3>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label={closeLabel}>
<X size={18} />
</button>
</div>
<div className={styles.body}>{children}</div>
</div>
</div>,
document.body,
)
}
@@ -124,6 +124,8 @@
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
font-family: var(--font-ui);
text-align: center;
}
@media (max-width: 768px) {
@@ -352,3 +352,18 @@ export function RichTextEditor({
</div>
)
}
/** 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(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}
+22 -4
View File
@@ -15,6 +15,7 @@ import {
Building2,
Pencil,
Package,
Wallet,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
@@ -61,8 +62,13 @@ interface NavLinkItem {
type NavItem = NavLinkItem | NavGroup
function isGroupActive(basePath: string, pathname: string) {
return pathname === basePath || pathname.startsWith(`${basePath}/`)
function isGroupActive(group: NavGroup, pathname: string) {
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
return true
}
return group.children.some(
(child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
)
}
export function Sidebar() {
@@ -130,6 +136,18 @@ export function Sidebar() {
],
},
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
{
type: 'group',
id: 'finance',
icon: Wallet,
labelKey: 'nav.finance',
basePath: '/finance',
children: [
{ labelKey: 'nav.finance.overview', to: '/finance' },
{ labelKey: 'nav.finance.invoices', to: '/invoices' },
{ labelKey: 'nav.finance.transactions', to: '/transactions' },
],
},
{
type: 'link',
id: 'customer-products',
@@ -227,7 +245,7 @@ export function Sidebar() {
useEffect(() => {
const activeGroup = navItems.find(
(item) => item.type === 'group' && isGroupActive(item.basePath, pathname),
(item) => item.type === 'group' && isGroupActive(item, pathname),
)
if (!activeGroup || activeGroup.type !== 'group') return
setOpenGroups({ [activeGroup.id]: true })
@@ -285,7 +303,7 @@ export function Sidebar() {
}
const isOpen = openGroups[item.id] ?? false
const groupActive = isGroupActive(item.basePath, pathname)
const groupActive = isGroupActive(item, pathname)
return (
<div key={item.id} className={styles.navGroup}>
@@ -2,9 +2,13 @@ import { useEffect, type ReactNode } from 'react'
import { isAbortError } from '../lib/api'
import { getBusinessDomain } from '../lib/config'
import { resolveTenantByDomain } from '../services/tenantService'
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { applyBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
/**
* Applies the tenant primary color. Does not reset to blue on unmount —
* Strict Mode / HMR remounts were wiping the brand color back to the default.
*/
export function BusinessThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController()
@@ -27,7 +31,6 @@ export function BusinessThemeProvider({ children }: { children: ReactNode }) {
return () => {
controller.abort()
resetBusinessPrimaryColor()
}
}, [])
@@ -15,6 +15,8 @@ import { getBusinessDomain } from '../lib/config'
import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext'
import { getBusinessProfile } from '../services/businessProfileService'
import { resolveTenantByDomain } from '../services/tenantService'
import { applyBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import {
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
@@ -114,6 +116,9 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setEnabledModules(normalizeEnabledBusinessModules(tenant.enabledModules))
setHomeCharts(normalizeHomeCharts(tenant.homeCharts))
applyDocumentFavicon(nextFavicon)
applyBusinessPrimaryColor(
normalizeBusinessPrimaryColorId(tenant.primaryColor),
)
if (!defaultLocaleAppliedRef.current) {
defaultLocaleAppliedRef.current = true
if (tenant.defaultLocale === 'en' || tenant.defaultLocale === 'fa') {
@@ -133,6 +138,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setEnabledModules([...DEFAULT_ENABLED_BUSINESS_MODULES])
setHomeCharts([...DEFAULT_HOME_CHARTS])
applyDocumentFavicon(null)
applyBusinessPrimaryColor(undefined)
}
}
+475 -4
View File
@@ -30,6 +30,13 @@ const en = {
'nav.store.settings': 'Settings',
'nav.customers': 'Users',
'nav.customerProducts': 'Customer products',
'nav.invoices': 'Invoices',
'nav.invoices.list': 'Invoices',
'nav.invoices.templates': 'Templates',
'nav.finance': 'Finance Management',
'nav.finance.overview': 'Overview',
'nav.finance.invoices': 'Invoices',
'nav.finance.transactions': 'Transactions',
'nav.settings': 'Settings',
'nav.blog': 'Blog',
'nav.blog.overview': 'Overview',
@@ -177,6 +184,9 @@ const en = {
'home.card.website.title': 'Website',
'home.card.website.desc': 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.',
'home.card.website.link': 'View website',
'home.card.finance.title': 'Finance Management',
'home.card.finance.desc': 'Manage invoices and financial transactions.',
'home.card.finance.link': 'View finance',
'home.chart.orders.title': 'Orders',
'home.chart.orders.subtitle': 'Orders and cart adds in the last 30 days',
@@ -200,8 +210,8 @@ const en = {
'home.chart.customersJoined.activeLegend': 'Active ({count})',
'home.chart.customersJoined.loading': 'Loading chart...',
'home.chart.customersJoined.error': 'Unable to load customer activity.',
'home.chart.customersJoined.bar': '{day}: {count} registered',
'home.chart.customersJoined.activeBar': '{day}: {count} active',
'home.chart.customersJoined.bar': '{month}: {count} registered',
'home.chart.customersJoined.activeBar': '{month}: {count} active',
'home.chart.blogViews.title': 'Article views',
'home.chart.blogViews.subtitle': 'Blog article views in the last 30 days',
'home.chart.placeholder.title': 'Chart',
@@ -802,6 +812,7 @@ const en = {
'customers.edit': 'Edit customer',
'customers.sms': 'Send SMS',
'customers.tickets': 'Tickets',
'customers.invoices': 'View invoices',
'customers.remove': 'Remove customer',
'customers.add': 'Add customer',
'customers.error.load': 'Unable to load customers.',
@@ -1290,6 +1301,222 @@ const en = {
'title.faq': 'FAQ',
'title.badges': 'Badges',
'title.ePayment': 'E-Payment',
'title.invoices': 'Invoices',
'title.finance': 'Finance Management',
'title.issueInvoice': 'Issue Invoice',
'title.editInvoice': 'Edit Invoice',
'title.invoiceTemplates': 'Invoice Templates',
'title.addInvoiceTemplate': 'Add Invoice Template',
'title.editInvoiceTemplate': 'Edit Invoice Template',
'title.transactions': 'Transactions',
'finance.page.subtitle': 'Manage invoices and financial transactions.',
'finance.card.invoices.desc': 'Issue and track invoices for your customers.',
'finance.card.transactions.desc': 'View payment and financial transactions.',
'transactions.subtitle': 'View payment and financial transactions.',
'transactions.lead': 'Transactions will appear here.',
'transactions.note': 'This section is coming soon. You can still review order payment details from Orders.',
'invoices.subtitle': 'Manage invoices issued to your customers.',
'invoices.templatesLink': 'Template management',
'invoices.issue': 'Issue invoice',
'invoices.tableTitle': 'Invoices',
'invoices.countOne': '{count} invoice',
'invoices.countMany': '{count} invoices',
'invoices.loading': 'Loading…',
'invoices.empty': 'No invoices yet.',
'invoices.col.name': 'Name',
'invoices.col.customer': 'Customer',
'invoices.col.issued': 'Issued',
'invoices.col.status': 'Status',
'invoices.col.total': 'Total',
'invoices.col.link': 'Link',
'invoices.col.actions': 'Actions',
'invoices.itemCountOne': '{count} item',
'invoices.itemCountMany': '{count} items',
'invoices.changeStatus': 'Change status',
'invoices.changeStatusAria': 'Change status ({status})',
'invoices.view': 'View',
'invoices.viewAria': 'View invoice',
'invoices.edit': 'Edit',
'invoices.editAria': 'Edit invoice',
'invoices.editDisabledTitle': 'Approved invoices cannot be edited',
'invoices.editDisabledAria': 'Edit invoice (disabled — approved)',
'invoices.remove': 'Remove',
'invoices.removeAria': 'Remove invoice',
'invoices.copyLink': 'Copy link',
'invoices.copyLinkAria': 'Copy invoice link',
'invoices.linkCopied': 'Invoice link copied.',
'invoices.linkCopyError': 'Unable to copy link.',
'invoices.deleteTitle': 'Remove invoice?',
'invoices.deleteMessage': 'Remove this invoice permanently? This cannot be undone.',
'invoices.removed': 'Invoice removed.',
'invoices.removeError': 'Unable to remove invoice.',
'invoices.statusUpdated': 'Invoice status updated.',
'invoices.statusUpdateError': 'Unable to update status.',
'invoices.loadError': 'Unable to load invoices.',
'invoices.filterBannerNamed': "{name}'s invoices",
'invoices.filterBannerGeneric': "This customer's invoices",
'invoices.filterCancel': 'Cancel',
'invoices.filterClear': 'Clear filter',
'invoices.issueForCustomer': 'Issue invoice for this customer',
'invoices.detailFallbackTitle': 'Invoice',
'invoices.detail.status': 'Status',
'invoices.detail.total': 'Total',
'invoices.detail.link': 'Public link',
'invoices.detail.billedTo': 'Billed to',
'invoices.detail.notes': 'Notes: {notes}',
'invoices.status.draft': 'Draft',
'invoices.status.issued': 'Issued',
'invoices.status.approved': 'Approved',
'invoices.status.paid': 'Paid',
'invoices.status.cancelled': 'Cancelled',
'issueInvoice.subtitleNew': 'Select an invoice template and edit it, or create a blank invoice.',
'issueInvoice.subtitleEdit': 'Update invoice content. Approved invoices cannot be changed.',
'issueInvoice.startFromTemplate': 'Start from template',
'issueInvoice.blankInvoice': 'Without template',
'issueInvoice.name': 'Name (optional)',
'issueInvoice.namePlaceholder': 'e.g. Website redesign package',
'issueInvoice.noTemplatesHint': 'No invoice templates yet. Manage them in {link}, or fill a blank invoice below.',
'issueInvoice.noTemplatesLink': 'Invoice Templates',
'issueInvoice.topText': 'Top text',
'issueInvoice.topTextPlaceholder': 'Optional intro text at the top of the invoice',
'issueInvoice.notes': 'Notes',
'issueInvoice.notesPlaceholder': 'Optional internal notes',
'issueInvoice.total': 'Total: {total}',
'issueInvoice.totalLabel': 'Total',
'issueInvoice.cancel': 'Cancel',
'issueInvoice.save': 'Save changes',
'issueInvoice.saving': 'Saving…',
'issueInvoice.issue': 'Issue invoice',
'issueInvoice.issuing': 'Issuing…',
'issueInvoice.backToInvoices': 'Back to invoices',
'issueInvoice.approvedLocked': 'This invoice is approved and can no longer be edited.',
'issueInvoice.loadError': 'Unable to load invoice form.',
'issueInvoice.updateError': 'Unable to update invoice.',
'issueInvoice.createError': 'Unable to create invoice.',
'issueInvoice.updated': 'Invoice updated.',
'issueInvoice.issued': 'Invoice issued.',
'issueInvoice.recipient': 'Issue invoice for',
'issueInvoice.recipientFor': 'Issue invoice for {name}',
'issueInvoice.recipientRequired': 'Select a customer to bill this invoice to.',
'issueInvoice.pickCustomer': 'Select customer',
'issueInvoice.pickCustomerPlaceholder': 'Search by name or cell number…',
'issueInvoice.pickCustomerNoResults': 'No customers found.',
'issueInvoice.pickCustomerHint': 'Type at least 2 characters to search.',
'issueInvoice.changeCustomer': 'Change',
'issueInvoice.unknownCustomer': 'Customer',
'issueInvoice.loading': 'Loading…',
'invoiceDraft.itemLabel': 'Item {index}',
'invoiceDraft.removeItem': 'Remove item',
'invoiceDraft.fieldTitle': 'Title',
'invoiceDraft.fieldTitlePlaceholder': 'Service title',
'invoiceDraft.fieldDuration': 'Duration',
'invoiceDraft.fieldDurationPlaceholder': 'e.g. 3 months',
'invoiceDraft.fieldWorktime': 'Worktime',
'invoiceDraft.fieldWorktimePlaceholder': 'e.g. 40 hours',
'invoiceDraft.fieldPrice': 'Price (IRT)',
'invoiceDraft.fieldDiscountedPrice': 'Discounted price (IRT)',
'invoiceDraft.fieldDiscountedPricePlaceholder': 'Optional',
'invoiceDraft.optional': 'Optional',
'invoiceDraft.fieldDescription': 'Description',
'invoiceDraft.fieldDescriptionPlaceholder': 'Optional details',
'invoiceDraft.addFromTemplate': 'Add from template…',
'invoiceDraft.addFromTemplateAria': 'Add from item template',
'invoiceDraft.add': 'Add',
'invoiceDraft.addCustomItem': 'Add custom item',
'invoiceDraft.keyPoints': 'Key points',
'invoiceDraft.addPoint': 'Add point',
'invoiceDraft.noKeyPoints': 'No key points yet.',
'invoiceDraft.keyPointPlaceholder': 'e.g. Payment due within 7 days',
'invoiceDraft.keyPointAria': 'Key point {index}',
'invoiceDraft.removeKeyPoint': 'Remove key point',
'invoiceDraft.accounts': 'Account numbers',
'invoiceDraft.addAccount': 'Add account',
'invoiceDraft.noAccounts': 'No bank accounts yet.',
'invoiceDraft.bankName': 'Bank name',
'invoiceDraft.accountHolder': 'Account holder',
'invoiceDraft.cardNumber': 'Card number',
'invoiceDraft.iban': 'IBAN',
'invoiceDraft.removeAccount': 'Remove account',
'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}".',
'invoiceTemplates.subtitle': 'Manage reusable invoice templates and predefined line items.',
'invoiceTemplates.section.templates.title': 'Invoice templates',
'invoiceTemplates.section.templates.desc':
'Full blueprints for faster issuing — pick one and edit, or start blank.',
'invoiceTemplates.addTemplate': 'Add template',
'invoiceTemplates.templatesTableTitle': 'Templates',
'invoiceTemplates.countTemplatesOne': '{count} template',
'invoiceTemplates.countTemplatesMany': '{count} templates',
'invoiceTemplates.col.name': 'Name',
'invoiceTemplates.col.items': 'Items',
'invoiceTemplates.col.keyPoints': 'Key points',
'invoiceTemplates.col.accounts': 'Accounts',
'invoiceTemplates.col.actions': 'Actions',
'invoiceTemplates.emptyTemplates': 'No invoice templates yet. Add one to speed up issuing invoices.',
'invoiceTemplates.section.items.title': 'Invoice item templates',
'invoiceTemplates.section.items.desc':
'Reusable line items you can drop into invoice templates or when issuing an invoice.',
'invoiceTemplates.addItem': 'Add item',
'invoiceTemplates.itemsTableTitle': 'Predefined line items',
'invoiceTemplates.countItemsOne': '{count} item',
'invoiceTemplates.countItemsMany': '{count} items',
'invoiceTemplates.col.title': 'Title',
'invoiceTemplates.col.duration': 'Duration',
'invoiceTemplates.col.worktime': 'Worktime',
'invoiceTemplates.col.price': 'Price',
'invoiceTemplates.col.discounted': 'Discounted',
'invoiceTemplates.emptyItems': 'No predefined items yet.',
'invoiceTemplates.editTemplate': 'Edit',
'invoiceTemplates.removeTemplate': 'Remove',
'invoiceTemplates.editItem': 'Edit',
'invoiceTemplates.removeItem': 'Remove',
'invoiceTemplates.deleteTemplateTitle': 'Remove invoice template',
'invoiceTemplates.deleteTemplateMessage': 'Remove "{name}" from invoice templates?',
'invoiceTemplates.deleteItemTitle': 'Remove item template',
'invoiceTemplates.deleteItemMessage': 'Remove "{title}" from predefined line items?',
'invoiceTemplates.itemModalAddTitle': 'Add item template',
'invoiceTemplates.itemModalEditTitle': 'Edit item template',
'invoiceTemplates.itemUpdated': 'Item template updated.',
'invoiceTemplates.itemCreated': 'Item template created.',
'invoiceTemplates.itemRemoved': 'Item template removed.',
'invoiceTemplates.templateRemoved': 'Invoice template removed.',
'invoiceTemplates.loadError': 'Unable to load invoice settings.',
'invoiceTemplates.itemSaveError': 'Unable to save item template.',
'invoiceTemplates.itemRemoveError': 'Unable to remove item.',
'invoiceTemplates.templateRemoveError': 'Unable to remove template.',
'invoiceTemplates.saveItem': 'Save changes',
'invoiceTemplates.savingItem': 'Saving…',
'invoiceTemplates.createItem': 'Create item',
'invoiceTemplates.cancel': 'Cancel',
'invoiceTemplates.hint': 'Tip: issue an invoice from the Invoices page using a template or from scratch.',
'invoiceTemplateEditor.addTitle': 'Add invoice template',
'invoiceTemplateEditor.editTitle': 'Edit invoice template',
'invoiceTemplateEditor.subtitle':
'Define name, top text, line items, key points, and bank accounts for reuse when issuing invoices.',
'invoiceTemplateEditor.name': 'Name',
'invoiceTemplateEditor.namePlaceholder': 'e.g. Standard website package',
'invoiceTemplateEditor.topText': 'Top text',
'invoiceTemplateEditor.topTextPlaceholder': 'Optional intro shown at the top of the invoice',
'invoiceTemplateEditor.nameRequired': 'Template name is required.',
'invoiceTemplateEditor.loadError': 'Unable to load template editor.',
'invoiceTemplateEditor.saveError': 'Unable to save invoice template.',
'invoiceTemplateEditor.updated': 'Invoice template updated.',
'invoiceTemplateEditor.created': 'Invoice template created.',
'invoiceTemplateEditor.backToTemplates': 'Back to templates',
'invoiceTemplateEditor.create': 'Create template',
'invoiceTemplateEditor.save': 'Save changes',
'invoiceTemplateEditor.saving': 'Saving…',
'invoiceTemplateEditor.cancel': 'Cancel',
'invoiceTemplateEditor.loading': 'Loading…',
'login.welcome': 'Welcome back',
'login.subtitle': 'Sign in with your mobile number',
@@ -1406,6 +1633,13 @@ const fa: Record<MessageKey, string> = {
'nav.store.settings': 'تنظیمات',
'nav.customers': 'کاربران',
'nav.customerProducts': 'محصولات مشتریان',
'nav.invoices': 'فاکتورها',
'nav.invoices.list': 'فاکتورها',
'nav.invoices.templates': 'قالب‌ها',
'nav.finance': 'مدیریت مالی',
'nav.finance.overview': 'نمای کلی',
'nav.finance.invoices': 'فاکتورها',
'nav.finance.transactions': 'تراکنش‌ها',
'nav.settings': 'تنظیمات',
'nav.blog': 'بلاگ',
'nav.blog.overview': 'نمای کلی',
@@ -1553,6 +1787,9 @@ const fa: Record<MessageKey, string> = {
'home.card.website.title': 'وب‌سایت',
'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها، عضویت‌ها و پرداخت الکترونیک.',
'home.card.website.link': 'مشاهده وب‌سایت',
'home.card.finance.title': 'مدیریت مالی',
'home.card.finance.desc': 'فاکتورها و تراکنش‌های مالی را مدیریت کنید.',
'home.card.finance.link': 'مشاهده مالی',
'home.chart.orders.title': 'سفارش‌ها',
'home.chart.orders.subtitle': 'سفارش‌ها در ۳۰ روز گذشته',
@@ -1576,8 +1813,8 @@ const fa: Record<MessageKey, string> = {
'home.chart.customersJoined.activeLegend': 'فعال ({count})',
'home.chart.customersJoined.loading': 'در حال بارگذاری نمودار...',
'home.chart.customersJoined.error': 'بارگذاری فعالیت مشتریان ممکن نشد.',
'home.chart.customersJoined.bar': '{day}: {count} ثبت‌نام',
'home.chart.customersJoined.activeBar': '{day}: {count} فعال',
'home.chart.customersJoined.bar': '{month}: {count} ثبت‌نام',
'home.chart.customersJoined.activeBar': '{month}: {count} فعال',
'home.chart.blogViews.title': 'بازدید مقالات',
'home.chart.blogViews.subtitle': 'بازدید مقالات در ۳۰ روز گذشته',
'home.chart.placeholder.title': 'نمودار',
@@ -2176,6 +2413,7 @@ const fa: Record<MessageKey, string> = {
'customers.edit': 'ویرایش مشتری',
'customers.sms': 'ارسال پیامک',
'customers.tickets': 'تیکت‌ها',
'customers.invoices': 'مشاهده فاکتورها',
'customers.remove': 'حذف مشتری',
'customers.add': 'افزودن مشتری',
'customers.error.load': 'بارگذاری مشتریان ممکن نشد.',
@@ -2663,6 +2901,222 @@ const fa: Record<MessageKey, string> = {
'title.faq': 'سوالات متداول',
'title.badges': 'نشان‌ها',
'title.ePayment': 'پرداخت الکترونیک',
'title.invoices': 'فاکتورها',
'title.finance': 'مدیریت مالی',
'title.issueInvoice': 'صدور فاکتور',
'title.editInvoice': 'ویرایش فاکتور',
'title.invoiceTemplates': 'قالب‌های فاکتور',
'title.addInvoiceTemplate': 'افزودن قالب فاکتور',
'title.editInvoiceTemplate': 'ویرایش قالب فاکتور',
'title.transactions': 'تراکنش‌ها',
'finance.page.subtitle': 'فاکتورها و تراکنش‌های مالی را مدیریت کنید.',
'finance.card.invoices.desc': 'صدور و پیگیری فاکتور برای مشتریان.',
'finance.card.transactions.desc': 'مشاهده تراکنش‌های پرداختی و مالی.',
'transactions.subtitle': 'تراکنش‌های پرداختی و مالی را مشاهده کنید.',
'transactions.lead': 'تراکنش‌ها اینجا نمایش داده می‌شوند.',
'transactions.note': 'این بخش به‌زودی آماده می‌شود. فعلاً می‌توانید جزئیات پرداخت سفارش را از صفحه سفارش‌ها ببینید.',
'invoices.subtitle': 'فاکتورهای صادر شده برای مشتریان خود را مدیریت کنید.',
'invoices.templatesLink': 'مدیریت قالب ها',
'invoices.issue': 'صدور فاکتور',
'invoices.tableTitle': 'فاکتورها',
'invoices.countOne': '{count} فاکتور',
'invoices.countMany': '{count} فاکتور',
'invoices.loading': 'در حال بارگذاری...',
'invoices.empty': 'هنوز فاکتوری ثبت نشده است.',
'invoices.col.name': 'نام',
'invoices.col.customer': 'مشتری',
'invoices.col.issued': 'تاریخ صدور',
'invoices.col.status': 'وضعیت',
'invoices.col.total': 'مبلغ کل',
'invoices.col.link': 'لینک',
'invoices.col.actions': 'عملیات',
'invoices.itemCountOne': '{count} آیتم',
'invoices.itemCountMany': '{count} آیتم',
'invoices.changeStatus': 'تغییر وضعیت',
'invoices.changeStatusAria': 'تغییر وضعیت ({status})',
'invoices.view': 'مشاهده',
'invoices.viewAria': 'مشاهده فاکتور',
'invoices.edit': 'ویرایش',
'invoices.editAria': 'ویرایش فاکتور',
'invoices.editDisabledTitle': 'فاکتورهای تاییدشده قابل ویرایش نیستند',
'invoices.editDisabledAria': 'ویرایش فاکتور (غیرفعال — تاییدشده)',
'invoices.remove': 'حذف',
'invoices.removeAria': 'حذف فاکتور',
'invoices.copyLink': 'کپی لینک',
'invoices.copyLinkAria': 'کپی لینک فاکتور',
'invoices.linkCopied': 'لینک فاکتور کپی شد.',
'invoices.linkCopyError': 'کپی لینک ممکن نشد.',
'invoices.deleteTitle': 'حذف فاکتور؟',
'invoices.deleteMessage': 'این فاکتور برای همیشه حذف شود؟ این عملیات قابل بازگشت نیست.',
'invoices.removed': 'فاکتور حذف شد.',
'invoices.removeError': 'حذف فاکتور ممکن نشد.',
'invoices.statusUpdated': 'وضعیت فاکتور به‌روزرسانی شد.',
'invoices.statusUpdateError': 'به‌روزرسانی وضعیت ممکن نشد.',
'invoices.loadError': 'بارگذاری فاکتورها ممکن نشد.',
'invoices.filterBannerNamed': 'فاکتور های {name}',
'invoices.filterBannerGeneric': 'فاکتور های این مشتری',
'invoices.filterCancel': 'انصراف',
'invoices.filterClear': 'پاک کردن فیلتر',
'invoices.issueForCustomer': 'صدور فاکتور برای این مشتری',
'invoices.detailFallbackTitle': 'فاکتور',
'invoices.detail.status': 'وضعیت',
'invoices.detail.total': 'مبلغ کل',
'invoices.detail.link': 'لینک عمومی',
'invoices.detail.billedTo': 'صادر شده برای',
'invoices.detail.notes': 'یادداشت: {notes}',
'invoices.status.draft': 'پیش‌نویس',
'invoices.status.issued': 'صادرشده',
'invoices.status.approved': 'تاییدشده',
'invoices.status.paid': 'پرداخت‌شده',
'invoices.status.cancelled': 'لغوشده',
'issueInvoice.subtitleNew': 'یک قالب فاکتور انتخاب و ویرایش کنید یا یک فاکتور خالی بسازید.',
'issueInvoice.subtitleEdit': 'محتوای فاکتور را به‌روزرسانی کنید. فاکتورهای تاییدشده قابل تغییر نیستند.',
'issueInvoice.startFromTemplate': 'شروع از قالب',
'issueInvoice.blankInvoice': 'بدون قالب',
'issueInvoice.name': 'نام (اختیاری)',
'issueInvoice.namePlaceholder': 'مثلاً بسته طراحی سایت',
'issueInvoice.noTemplatesHint': 'هنوز قالب فاکتوری ندارید. آن‌ها را در {link} مدیریت کنید یا فاکتور خالی زیر را تکمیل کنید.',
'issueInvoice.noTemplatesLink': 'قالب‌های فاکتور',
'issueInvoice.topText': 'متن بالای فاکتور',
'issueInvoice.topTextPlaceholder': 'متن مقدماتی اختیاری در بالای فاکتور',
'issueInvoice.notes': 'یادداشت‌ها',
'issueInvoice.notesPlaceholder': 'یادداشت داخلی اختیاری',
'issueInvoice.total': 'مبلغ کل: {total}',
'issueInvoice.totalLabel': 'مبلغ کل',
'issueInvoice.cancel': 'انصراف',
'issueInvoice.save': 'ذخیره تغییرات',
'issueInvoice.saving': 'در حال ذخیره...',
'issueInvoice.issue': 'صدور فاکتور',
'issueInvoice.issuing': 'در حال صدور...',
'issueInvoice.backToInvoices': 'بازگشت به فاکتورها',
'issueInvoice.approvedLocked': 'این فاکتور تاییدشده و دیگر قابل ویرایش نیست.',
'issueInvoice.loadError': 'بارگذاری فرم فاکتور ممکن نشد.',
'issueInvoice.updateError': 'به‌روزرسانی فاکتور ممکن نشد.',
'issueInvoice.createError': 'ایجاد فاکتور ممکن نشد.',
'issueInvoice.updated': 'فاکتور به‌روزرسانی شد.',
'issueInvoice.issued': 'فاکتور صادر شد.',
'issueInvoice.recipient': 'صدور فاکتور برای',
'issueInvoice.recipientFor': 'صدور فاکتور برای {name}',
'issueInvoice.recipientRequired': 'یک مشتری برای صدور این فاکتور انتخاب کنید.',
'issueInvoice.pickCustomer': 'انتخاب مشتری',
'issueInvoice.pickCustomerPlaceholder': 'جستجو بر اساس نام یا شماره تماس...',
'issueInvoice.pickCustomerNoResults': 'مشتری‌ای یافت نشد.',
'issueInvoice.pickCustomerHint': 'برای جستجو حداقل ۲ کاراکتر وارد کنید.',
'issueInvoice.changeCustomer': 'تغییر',
'issueInvoice.unknownCustomer': 'مشتری',
'issueInvoice.loading': 'در حال بارگذاری...',
'invoiceDraft.itemLabel': 'آیتم {index}',
'invoiceDraft.removeItem': 'حذف آیتم',
'invoiceDraft.fieldTitle': 'عنوان',
'invoiceDraft.fieldTitlePlaceholder': 'عنوان خدمت',
'invoiceDraft.fieldDuration': 'مدت',
'invoiceDraft.fieldDurationPlaceholder': 'مثلاً ۳ ماه',
'invoiceDraft.fieldWorktime': 'ساعت کاری',
'invoiceDraft.fieldWorktimePlaceholder': 'مثلاً ۴۰ ساعت',
'invoiceDraft.fieldPrice': 'قیمت (تومان)',
'invoiceDraft.fieldDiscountedPrice': 'قیمت با تخفیف (تومان)',
'invoiceDraft.fieldDiscountedPricePlaceholder': 'اختیاری',
'invoiceDraft.optional': 'اختیاری',
'invoiceDraft.fieldDescription': 'توضیحات',
'invoiceDraft.fieldDescriptionPlaceholder': 'جزئیات اختیاری',
'invoiceDraft.addFromTemplate': 'افزودن از قالب...',
'invoiceDraft.addFromTemplateAria': 'افزودن از قالب آیتم',
'invoiceDraft.add': 'افزودن',
'invoiceDraft.addCustomItem': 'افزودن آیتم سفارشی',
'invoiceDraft.keyPoints': 'نکات کلیدی',
'invoiceDraft.addPoint': 'افزودن نکته',
'invoiceDraft.noKeyPoints': 'هنوز نکته کلیدی‌ای ثبت نشده است.',
'invoiceDraft.keyPointPlaceholder': 'مثلاً پرداخت ظرف ۷ روز',
'invoiceDraft.keyPointAria': 'نکته کلیدی {index}',
'invoiceDraft.removeKeyPoint': 'حذف نکته کلیدی',
'invoiceDraft.accounts': 'شماره حساب‌ها',
'invoiceDraft.addAccount': 'افزودن حساب',
'invoiceDraft.noAccounts': 'هنوز حساب بانکی‌ای ثبت نشده است.',
'invoiceDraft.bankName': 'نام بانک',
'invoiceDraft.accountHolder': 'صاحب حساب',
'invoiceDraft.cardNumber': 'شماره کارت',
'invoiceDraft.iban': 'شبا',
'invoiceDraft.removeAccount': 'حذف حساب',
'invoiceDraft.error.itemTitle': 'هر آیتم به عنوان نیاز دارد.',
'invoiceDraft.error.itemPrice': 'قیمت برای «{title}» الزامی است.',
'invoiceDraft.error.itemDiscountInvalid': 'قیمت با تخفیف برای «{title}» نامعتبر است.',
'invoiceDraft.error.itemDiscountExceeds': 'قیمت با تخفیف نمی‌تواند از قیمت اصلی «{title}» بیشتر باشد.',
'invoiceTemplates.subtitle': 'قالب‌های فاکتور و آیتم‌های از پیش تعریف‌شده را مدیریت کنید.',
'invoiceTemplates.section.templates.title': 'قالب‌های فاکتور',
'invoiceTemplates.section.templates.desc':
'الگوهای کامل برای صدور سریع‌تر فاکتور — یکی را انتخاب کنید یا از خالی شروع کنید.',
'invoiceTemplates.addTemplate': 'افزودن قالب',
'invoiceTemplates.templatesTableTitle': 'قالب‌ها',
'invoiceTemplates.countTemplatesOne': '{count} قالب',
'invoiceTemplates.countTemplatesMany': '{count} قالب',
'invoiceTemplates.col.name': 'نام',
'invoiceTemplates.col.items': 'آیتم‌ها',
'invoiceTemplates.col.keyPoints': 'نکات کلیدی',
'invoiceTemplates.col.accounts': 'حساب‌ها',
'invoiceTemplates.col.actions': 'عملیات',
'invoiceTemplates.emptyTemplates': 'هنوز قالب فاکتوری ندارید. یکی اضافه کنید تا صدور فاکتور سریع‌تر شود.',
'invoiceTemplates.section.items.title': 'قالب‌های آیتم فاکتور',
'invoiceTemplates.section.items.desc':
'آیتم‌های قابل استفاده مجدد که می‌توانید در قالب‌های فاکتور یا هنگام صدور فاکتور استفاده کنید.',
'invoiceTemplates.addItem': 'افزودن آیتم',
'invoiceTemplates.itemsTableTitle': 'آیتم‌های از پیش تعریف‌شده',
'invoiceTemplates.countItemsOne': '{count} آیتم',
'invoiceTemplates.countItemsMany': '{count} آیتم',
'invoiceTemplates.col.title': 'عنوان',
'invoiceTemplates.col.duration': 'مدت',
'invoiceTemplates.col.worktime': 'ساعت کاری',
'invoiceTemplates.col.price': 'قیمت',
'invoiceTemplates.col.discounted': 'با تخفیف',
'invoiceTemplates.emptyItems': 'هنوز آیتم از پیش تعریف‌شده‌ای ندارید.',
'invoiceTemplates.editTemplate': 'ویرایش',
'invoiceTemplates.removeTemplate': 'حذف',
'invoiceTemplates.editItem': 'ویرایش',
'invoiceTemplates.removeItem': 'حذف',
'invoiceTemplates.deleteTemplateTitle': 'حذف قالب فاکتور',
'invoiceTemplates.deleteTemplateMessage': '«{name}» از قالب‌های فاکتور حذف شود؟',
'invoiceTemplates.deleteItemTitle': 'حذف قالب آیتم',
'invoiceTemplates.deleteItemMessage': '«{title}» از آیتم‌های از پیش تعریف‌شده حذف شود؟',
'invoiceTemplates.itemModalAddTitle': 'افزودن قالب آیتم',
'invoiceTemplates.itemModalEditTitle': 'ویرایش قالب آیتم',
'invoiceTemplates.itemUpdated': 'قالب آیتم به‌روزرسانی شد.',
'invoiceTemplates.itemCreated': 'قالب آیتم ایجاد شد.',
'invoiceTemplates.itemRemoved': 'قالب آیتم حذف شد.',
'invoiceTemplates.templateRemoved': 'قالب فاکتور حذف شد.',
'invoiceTemplates.loadError': 'بارگذاری تنظیمات فاکتور ممکن نشد.',
'invoiceTemplates.itemSaveError': 'ذخیره قالب آیتم ممکن نشد.',
'invoiceTemplates.itemRemoveError': 'حذف آیتم ممکن نشد.',
'invoiceTemplates.templateRemoveError': 'حذف قالب ممکن نشد.',
'invoiceTemplates.saveItem': 'ذخیره تغییرات',
'invoiceTemplates.savingItem': 'در حال ذخیره...',
'invoiceTemplates.createItem': 'ایجاد آیتم',
'invoiceTemplates.cancel': 'انصراف',
'invoiceTemplates.hint': 'نکته: از صفحه فاکتورها با استفاده از یک قالب یا از ابتدا فاکتور صادر کنید.',
'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور',
'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور',
'invoiceTemplateEditor.subtitle':
'نام، متن بالا، آیتم‌ها، نکات کلیدی و حساب‌های بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.',
'invoiceTemplateEditor.name': 'نام',
'invoiceTemplateEditor.namePlaceholder': 'مثلاً بسته استاندارد سایت',
'invoiceTemplateEditor.topText': 'متن بالای فاکتور',
'invoiceTemplateEditor.topTextPlaceholder': 'متن مقدماتی اختیاری در بالای فاکتور',
'invoiceTemplateEditor.nameRequired': 'نام قالب الزامی است.',
'invoiceTemplateEditor.loadError': 'بارگذاری ویرایشگر قالب ممکن نشد.',
'invoiceTemplateEditor.saveError': 'ذخیره قالب فاکتور ممکن نشد.',
'invoiceTemplateEditor.updated': 'قالب فاکتور به‌روزرسانی شد.',
'invoiceTemplateEditor.created': 'قالب فاکتور ایجاد شد.',
'invoiceTemplateEditor.backToTemplates': 'بازگشت به قالب‌ها',
'invoiceTemplateEditor.create': 'ایجاد قالب',
'invoiceTemplateEditor.save': 'ذخیره تغییرات',
'invoiceTemplateEditor.saving': 'در حال ذخیره...',
'invoiceTemplateEditor.cancel': 'انصراف',
'invoiceTemplateEditor.loading': 'در حال بارگذاری...',
'login.welcome': 'خوش آمدید',
'login.subtitle': 'با شماره موبایل وارد شوید',
@@ -2844,10 +3298,27 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/store/settings', labels: [t('title.store'), t('title.settings')] },
{ match: '/store', labels: [t('title.store')] },
{ match: '/customers', labels: [t('title.customers')] },
{ match: '/finance', labels: [t('title.finance')] },
{ match: '/customer-products/new', labels: [t('title.customerProducts'), t('customerProducts.add')] },
{ match: /^\/customer-products\/[^/]+\/edit$/, labels: [t('title.customerProducts'), t('customerProducts.edit')] },
{ match: /^\/customer-products\/[^/]+$/, labels: [t('title.customerProducts'), t('customerProducts.detailsTitle')] },
{ match: '/customer-products', labels: [t('title.customerProducts')] },
{
match: /^\/invoices\/templates\/new$/,
labels: [t('title.finance'), t('title.invoiceTemplates'), t('title.addInvoiceTemplate')],
},
{
match: /^\/invoices\/templates\/[^/]+$/,
labels: [t('title.finance'), t('title.invoiceTemplates'), t('title.editInvoiceTemplate')],
},
{ match: '/invoices/templates', labels: [t('title.finance'), t('title.invoiceTemplates')] },
{ match: '/invoices/new', labels: [t('title.finance'), t('title.invoices'), t('title.issueInvoice')] },
{
match: /^\/invoices\/[^/]+\/edit$/,
labels: [t('title.finance'), t('title.invoices'), t('title.editInvoice')],
},
{ match: '/invoices', labels: [t('title.finance'), t('title.invoices')] },
{ match: '/transactions', labels: [t('title.finance'), t('title.transactions')] },
{ match: '/blog/list', labels: [t('title.blog'), t('title.myBlogs')] },
{ match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] },
{ match: '/blog/new', labels: [t('title.blog'), t('title.addBlog')] },
+6
View File
@@ -21,6 +21,12 @@
--bg-gradient-mid: color-mix(in srgb, var(--primary-light) 42%, #ffffff);
--bg-gradient-end: color-mix(in srgb, var(--primary-light) 18%, #ffffff);
--glass-bg: rgba(255, 255, 255, 0.55);
/* Sticky header: opaque enough over scrolling content; tinted by theme (works for near-white primary-light e.g. brown) */
--header-bg: color-mix(
in srgb,
color-mix(in srgb, var(--primary-light) 42%, #ffffff) 90%,
transparent
);
--glass-border: rgba(255, 255, 255, 0.7);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08);
--blur-glass: 28px;
+20
View File
@@ -34,3 +34,23 @@ export function isAllowedBusinessHost(hostname = window.location.hostname): bool
export function getBusinessDomain(): string {
return getBaseBusinessDomain()
}
/**
* Fallback public invoice URL if the API response is missing `publicUrl`.
* Prefer `invoice.publicUrl` returned by the API this only covers edge cases.
* Business invoices use the tenant domain (e.g. sanihome.ir), not meshkee.com.
*/
export function getInvoicePublicUrlFallback(
publicId: string,
businessDomain = getBusinessDomain(),
): string {
const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim()
if (baseOverride) {
return `${baseOverride.replace(/\/$/, '')}/invoices/${publicId}`
}
const domain =
import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() ||
businessDomain.trim() ||
'meshkee.com'
return `https://${domain}/invoices/${publicId}`
}
+18 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { MessageSquare, Pencil, Plus, Receipt, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
@@ -70,6 +71,7 @@ function teamRoleMessageKey(teamRole: string): BusinessMessageKey {
export function CustomersPage() {
const t = useT()
const navigate = useNavigate()
const { locale } = useLocale()
const { user: authUser } = useAuth()
const { showToast } = useToast()
@@ -243,6 +245,10 @@ export function CustomersPage() {
}
}
function handleViewInvoices(customer: BusinessCustomerListItem) {
navigate(`/invoices?userId=${customer.id}`, { state: { presetCustomer: customer } })
}
function handleSendSms(customer: BusinessCustomerListItem) {
showToast(
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
@@ -352,8 +358,7 @@ export function CustomersPage() {
<option value="managers">{t('customers.filter.access.managers')}</option>
</select>
</div>
<div className={filterStyles.filterSpacerCol4} aria-hidden="true" />
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsCol1}`}>
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsEnd}`}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
@@ -532,6 +537,16 @@ export function CustomersPage() {
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.invoices')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => handleViewInvoices(customer)}
aria-label={t('customers.invoices')}
>
<Receipt size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.sms')}>
<button
type="button"
+60
View File
@@ -0,0 +1,60 @@
import { FileText, ArrowLeftRight } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
const financeSections: {
icon: typeof FileText
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
href: string
}[] = [
{
icon: FileText,
titleKey: 'nav.finance.invoices',
descKey: 'finance.card.invoices.desc',
href: '/invoices',
},
{
icon: ArrowLeftRight,
titleKey: 'nav.finance.transactions',
descKey: 'finance.card.transactions.desc',
href: '/transactions',
},
]
export function FinancePage() {
const t = useT()
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('title.finance') },
]}
/>
<div className={styles.pageHeader}>
<div>
<PageTitle en="FINANCE MANAGEMENT">{t('title.finance')}</PageTitle>
<p className={styles.pageSubtitle}>{t('finance.page.subtitle')}</p>
</div>
</div>
<div className={styles.gridHome}>
{financeSections.map((section) => (
<SectionCard
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
href={section.href}
/>
))}
</div>
</main>
)
}
+8 -8
View File
@@ -6,7 +6,7 @@ import {
Users,
FileText,
Briefcase,
Globe,
Wallet,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
@@ -69,6 +69,13 @@ const sections: {
href: '/customers',
countKey: 'customers',
},
{
icon: Wallet,
titleKey: 'home.card.finance.title',
descKey: 'home.card.finance.desc',
linkKey: 'home.card.finance.link',
href: '/finance',
},
{
icon: FileText,
titleKey: 'home.card.blog.title',
@@ -89,13 +96,6 @@ const sections: {
countKey: 'portfolios',
moduleId: 'portfolio',
},
{
icon: Globe,
titleKey: 'home.card.website.title',
descKey: 'home.card.website.desc',
linkKey: 'home.card.website.link',
href: '/website',
},
]
type SectionCounts = Partial<Record<CountKey, number>>
@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { InvoiceDraftFields } from '../components/InvoiceDraftFields'
import { isEmptyRichText, RichTextEditor } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
createInvoiceTemplate,
getInvoiceTemplate,
listInvoiceItemTemplates,
updateInvoiceTemplate,
} from '../services/invoiceService'
import type { InvoiceItemTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildTemplateItemsPayload,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
type DraftKeyPoint,
type DraftLineItem,
} from '../utils/invoiceDraft'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import tableStyles from './InvoicesPage.module.css'
export function InvoiceTemplateEditorPage() {
const { templateId } = useParams()
const isEdit = Boolean(templateId)
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [loading, setLoading] = useState(isEdit)
const [error, setError] = useState('')
const [name, setName] = useState('')
const [topText, setTopText] = useState('')
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('')
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const itemsRes = await listInvoiceItemTemplates(controller.signal)
setItemTemplates(itemsRes.items.filter((i) => i.isActive))
if (templateId) {
const template = await getInvoiceTemplate(templateId, controller.signal)
const drafts = draftsFromInvoiceTemplate(template)
setName(drafts.name)
setTopText(drafts.topText)
setItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
}
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.loadError'))
} finally {
setLoading(false)
}
}
void load()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId])
async function handleSave() {
setFormError('')
if (!name.trim()) {
setFormError(t('invoiceTemplateEditor.nameRequired'))
return
}
let lineItems
try {
lineItems = buildTemplateItemsPayload(items, t)
} catch (err) {
setFormError(err instanceof Error ? err.message : t('invoiceTemplateEditor.saveError'))
return
}
const payload = {
name: name.trim(),
topText: isEmptyRichText(topText) ? undefined : topText,
items: lineItems,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
}
setSubmitting(true)
try {
if (templateId) {
await updateInvoiceTemplate(templateId, payload)
showToast(t('invoiceTemplateEditor.updated'), 'success')
} else {
await createInvoiceTemplate(payload)
showToast(t('invoiceTemplateEditor.created'), 'success')
}
navigate('/invoices/templates')
} catch (err) {
setFormError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.saveError'))
} finally {
setSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<Link to="/invoices/templates" className={formStyles.backLink}>
<ArrowLeft size={16} />
{t('invoiceTemplateEditor.backToTemplates')}
</Link>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('invoiceTemplateEditor.editTitle') : t('invoiceTemplateEditor.addTitle')}
</h2>
<p className={pageStyles.pageSubtitle}>{t('invoiceTemplateEditor.subtitle')}</p>
</div>
</div>
{error ? <p className={formStyles.alertError}>{error}</p> : null}
{loading ? <p className={tableStyles.meta}>{t('invoiceTemplateEditor.loading')}</p> : null}
{!loading ? (
<section className={formStyles.section}>
<div className={formStyles.metaGrid}>
<div className={`${formStyles.field} ${formStyles.span2}`}>
<label htmlFor="tpl-name">{t('invoiceTemplateEditor.name')}</label>
<input
id="tpl-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('invoiceTemplateEditor.namePlaceholder')}
/>
</div>
</div>
<div className={`${formStyles.field} ${formStyles.fullField}`}>
<label>{t('invoiceTemplateEditor.topText')}</label>
<RichTextEditor
value={topText}
onChange={setTopText}
placeholder={t('invoiceTemplateEditor.topTextPlaceholder')}
editorMinHeight={88}
/>
</div>
<InvoiceDraftFields
itemTemplates={itemTemplates}
items={items}
keyPoints={keyPoints}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
onItemsChange={setItems}
onKeyPointsChange={setKeyPoints}
onAccountsChange={setAccounts}
/>
{formError ? <p className={formStyles.alertError}>{formError}</p> : null}
<div className={formStyles.actionsRow}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => navigate('/invoices/templates')}
disabled={submitting}
>
{t('invoiceTemplateEditor.cancel')}
</button>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => void handleSave()}
disabled={submitting}
>
{submitting
? t('invoiceTemplateEditor.saving')
: isEdit
? t('invoiceTemplateEditor.save')
: t('invoiceTemplateEditor.create')}
</button>
</div>
</section>
) : null}
</main>
)
}
@@ -0,0 +1,117 @@
.sectionHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.sectionTitleRow {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--primary);
}
.sectionTitle {
margin: 0 0 2px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.sectionSubtitle {
margin: 0;
font-size: 12.5px;
color: var(--text-secondary);
max-width: min(560px, 100%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.emptyState {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 4px;
color: var(--text-secondary);
font-size: 13px;
}
.topTextPreview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin-top: 16px;
font-size: 12.5px;
color: var(--text-secondary);
}
.hint a {
color: var(--primary);
font-weight: 600;
}
.hint a:hover {
text-decoration: underline;
}
/* Invoice templates list: name+subtext ~ half width (6/12) */
.invoiceTemplatesTable col.nameCol {
width: 50%;
}
.invoiceTemplatesTable col.countCol {
width: 12%;
}
.invoiceTemplatesTable col.actionsCol {
width: 14%;
}
/* Item templates: title+desc ~ half width (6/12), rest share the other half */
.itemTemplatesTable col.nameCol {
width: 50%;
}
.itemTemplatesTable col.durationCol,
.itemTemplatesTable col.worktimeCol {
width: 10%;
}
.itemTemplatesTable col.priceCol,
.itemTemplatesTable col.discountedCol {
width: 11%;
}
.itemTemplatesTable col.actionsCol {
width: 8%;
}
.nameCell {
min-width: 0;
}
.nameCell .subText,
.nameCellDesc,
.nameCell .topTextPreview {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
word-break: break-word;
line-height: 1.4;
margin-top: 2px;
max-width: none;
}
@media (max-width: 720px) {
.sectionHeader {
flex-direction: column;
}
}
@@ -0,0 +1,542 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { PageTitle } from '../components/PageTitle'
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
createInvoiceItemTemplate,
deleteInvoiceItemTemplate,
deleteInvoiceTemplate,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateInvoiceItemTemplate,
} from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import tableStyles from './InvoicesPage.module.css'
import styles from './InvoiceTemplatesPage.module.css'
type ItemDraft = {
title: string
duration: string
worktime: string
description: string
price: string
discountedPrice: string
}
const EMPTY_ITEM_DRAFT: ItemDraft = {
title: '',
duration: '',
worktime: '',
description: '',
price: '',
discountedPrice: '',
}
function itemDraftFromTemplate(template: InvoiceItemTemplate): ItemDraft {
return {
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 InvoiceTemplatesPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [itemEditorOpen, setItemEditorOpen] = useState(false)
const [editingItem, setEditingItem] = useState<InvoiceItemTemplate | null>(null)
const [itemDraft, setItemDraft] = useState<ItemDraft>(EMPTY_ITEM_DRAFT)
const [itemSubmitting, setItemSubmitting] = useState(false)
const [itemFormError, setItemFormError] = useState('')
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | null>(null)
function itemDraftToPayload(draft: ItemDraft) {
const price = parseIrtInput(draft.price)
if (!draft.title.trim()) throw new Error(t('invoiceDraft.error.itemTitle'))
if (price === null) throw new Error(t('invoiceDraft.error.itemPrice', { title: draft.title.trim() }))
const discountedPrice = draft.discountedPrice.trim() ? parseIrtInput(draft.discountedPrice) : null
if (draft.discountedPrice.trim() && discountedPrice === null) {
throw new Error(t('invoiceDraft.error.itemDiscountInvalid', { title: draft.title.trim() }))
}
if (discountedPrice !== null && discountedPrice > price) {
throw new Error(t('invoiceDraft.error.itemDiscountExceeds', { title: draft.title.trim() }))
}
return {
title: draft.title.trim(),
duration: draft.duration.trim() || undefined,
worktime: draft.worktime.trim() || undefined,
description: draft.description.trim() || undefined,
price,
discountedPrice,
}
}
async function reload(signal?: AbortSignal) {
setLoading(true)
setError('')
try {
const [items, templates] = await Promise.all([
listInvoiceItemTemplates(signal),
listInvoiceTemplates(signal),
])
setItemTemplates(items.items)
setInvoiceTemplates(templates.items)
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('invoiceTemplates.loadError'))
} finally {
setLoading(false)
}
}
useEffect(() => {
const controller = new AbortController()
void reload(controller.signal)
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function openCreateItem() {
setEditingItem(null)
setItemDraft(EMPTY_ITEM_DRAFT)
setItemFormError('')
setItemEditorOpen(true)
}
function openEditItem(template: InvoiceItemTemplate) {
setEditingItem(template)
setItemDraft(itemDraftFromTemplate(template))
setItemFormError('')
setItemEditorOpen(true)
}
async function handleSaveItem() {
setItemFormError('')
let payload
try {
payload = itemDraftToPayload(itemDraft)
} catch (err) {
setItemFormError(err instanceof Error ? err.message : t('invoiceTemplates.itemSaveError'))
return
}
setItemSubmitting(true)
try {
if (editingItem) {
await updateInvoiceItemTemplate(editingItem.id, payload)
showToast(t('invoiceTemplates.itemUpdated'), 'success')
} else {
await createInvoiceItemTemplate(payload)
showToast(t('invoiceTemplates.itemCreated'), 'success')
}
setItemEditorOpen(false)
await reload()
} catch (err) {
setItemFormError(err instanceof ApiError ? err.message : t('invoiceTemplates.itemSaveError'))
} finally {
setItemSubmitting(false)
}
}
async function handleRemoveItem() {
if (!removeItemTarget) return
try {
await deleteInvoiceItemTemplate(removeItemTarget.id)
showToast(t('invoiceTemplates.itemRemoved'), 'success')
setRemoveItemTarget(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoiceTemplates.itemRemoveError'), 'error')
}
}
async function handleRemoveTpl() {
if (!removeTplTarget) return
try {
await deleteInvoiceTemplate(removeTplTarget.id)
showToast(t('invoiceTemplates.templateRemoved'), 'success')
setRemoveTplTarget(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoiceTemplates.templateRemoveError'), 'error')
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('nav.finance'), href: '/finance' },
{ label: t('title.invoices'), href: '/invoices' },
{ label: t('title.invoiceTemplates') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="INVOICE TEMPLATES">{t('title.invoiceTemplates')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('invoiceTemplates.subtitle')}</p>
</div>
</div>
{error ? <p className={formStyles.alertError}>{error}</p> : null}
<section className={formStyles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitleRow}>
<FileStack size={18} />
<div>
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.templates.title')}</h3>
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.templates.desc')}</p>
</div>
</div>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => navigate('/invoices/templates/new')}
>
<Plus size={16} />
{t('invoiceTemplates.addTemplate')}
</button>
</div>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.meta}>
{loading
? t('invoices.loading')
: invoiceTemplates.length === 1
? t('invoiceTemplates.countTemplatesOne', { count: invoiceTemplates.length })
: t('invoiceTemplates.countTemplatesMany', { count: invoiceTemplates.length })}
</div>
</div>
<table className={`${tableStyles.table} ${styles.invoiceTemplatesTable}`}>
<colgroup>
<col className={styles.nameCol} />
<col className={styles.countCol} />
<col className={styles.countCol} />
<col className={styles.countCol} />
<col className={styles.actionsCol} />
</colgroup>
<thead>
<tr>
<th className={tableStyles.th}>{t('invoiceTemplates.col.name')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.items')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.keyPoints')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.accounts')}</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
{t('invoiceTemplates.col.actions')}
</th>
</tr>
</thead>
<tbody>
{!loading && invoiceTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={5}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>{t('invoiceTemplates.emptyTemplates')}</span>
</div>
</td>
</tr>
) : null}
{invoiceTemplates.map((template) => (
<tr key={template.id}>
<td className={`${tableStyles.td} ${styles.nameCell}`}>
<div className={tableStyles.itemTitle}>{template.name}</div>
{template.topText && !isEmptyRichText(template.topText) ? (
<div
className={`${tableStyles.subText} ${styles.topTextPreview}`}
title={richTextToPlain(template.topText)}
>
{richTextToPlain(template.topText)}
</div>
) : null}
</td>
<td className={tableStyles.td}>{template.items.length}</td>
<td className={tableStyles.td}>{template.keyPoints.length}</td>
<td className={tableStyles.td}>{template.accounts.length}</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<Tooltip label={t('invoiceTemplates.editTemplate')}>
<button
type="button"
className={tableStyles.actionBtn}
onClick={() => navigate(`/invoices/templates/${template.id}`)}
aria-label={t('invoiceTemplates.editTemplate')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('invoiceTemplates.removeTemplate')}>
<button
type="button"
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
onClick={() => setRemoveTplTarget(template)}
aria-label={t('invoiceTemplates.removeTemplate')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={`${formStyles.section} ${formStyles.sectionSpaced}`}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitleRow}>
<FileText size={18} />
<div>
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.items.title')}</h3>
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.items.desc')}</p>
</div>
</div>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={openCreateItem}
>
<Plus size={16} />
{t('invoiceTemplates.addItem')}
</button>
</div>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.meta}>
{loading
? t('invoices.loading')
: itemTemplates.length === 1
? t('invoiceTemplates.countItemsOne', { count: itemTemplates.length })
: t('invoiceTemplates.countItemsMany', { count: itemTemplates.length })}
</div>
</div>
<table className={`${tableStyles.table} ${styles.itemTemplatesTable}`}>
<colgroup>
<col className={styles.nameCol} />
<col className={styles.durationCol} />
<col className={styles.worktimeCol} />
<col className={styles.priceCol} />
<col className={styles.discountedCol} />
<col className={styles.actionsCol} />
</colgroup>
<thead>
<tr>
<th className={tableStyles.th}>{t('invoiceTemplates.col.title')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.duration')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.worktime')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.price')}</th>
<th className={tableStyles.th}>{t('invoiceTemplates.col.discounted')}</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
{t('invoiceTemplates.col.actions')}
</th>
</tr>
</thead>
<tbody>
{!loading && itemTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={6}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>{t('invoiceTemplates.emptyItems')}</span>
</div>
</td>
</tr>
) : null}
{itemTemplates.map((template) => (
<tr key={template.id}>
<td className={`${tableStyles.td} ${styles.nameCell}`}>
<div className={tableStyles.itemTitle}>{template.title}</div>
{template.description ? (
<div className={`${tableStyles.subText} ${styles.nameCellDesc}`}>
{template.description}
</div>
) : null}
</td>
<td className={tableStyles.td}>{template.duration || '—'}</td>
<td className={tableStyles.td}>{template.worktime || '—'}</td>
<td className={tableStyles.td}>{formatIrtPrice(template.price)}</td>
<td className={tableStyles.td}>
{template.discountedPrice === null ? '—' : formatIrtPrice(template.discountedPrice)}
</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<Tooltip label={t('invoiceTemplates.editItem')}>
<button
type="button"
className={tableStyles.actionBtn}
onClick={() => openEditItem(template)}
aria-label={t('invoiceTemplates.editItem')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('invoiceTemplates.removeItem')}>
<button
type="button"
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
onClick={() => setRemoveItemTarget(template)}
aria-label={t('invoiceTemplates.removeItem')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p className={styles.hint}>{t('invoiceTemplates.hint')}</p>
<Modal
open={itemEditorOpen}
title={editingItem ? t('invoiceTemplates.itemModalEditTitle') : t('invoiceTemplates.itemModalAddTitle')}
onClose={() => !itemSubmitting && setItemEditorOpen(false)}
wide
>
<div className={formStyles.formGrid}>
<div className={`${formStyles.field} ${formStyles.span2}`}>
<label htmlFor="item-title">{t('invoiceDraft.fieldTitle')}</label>
<input
id="item-title"
value={itemDraft.title}
onChange={(e) => setItemDraft((d) => ({ ...d, title: e.target.value }))}
placeholder={t('invoiceDraft.fieldTitlePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-duration">{t('invoiceDraft.fieldDuration')}</label>
<input
id="item-duration"
value={itemDraft.duration}
onChange={(e) => setItemDraft((d) => ({ ...d, duration: e.target.value }))}
placeholder={t('invoiceDraft.fieldDurationPlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-worktime">{t('invoiceDraft.fieldWorktime')}</label>
<input
id="item-worktime"
value={itemDraft.worktime}
onChange={(e) => setItemDraft((d) => ({ ...d, worktime: e.target.value }))}
placeholder={t('invoiceDraft.fieldWorktimePlaceholder')}
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-price">{t('invoiceDraft.fieldPrice')}</label>
<input
id="item-price"
inputMode="numeric"
value={itemDraft.price}
onChange={(e) => setItemDraft((d) => ({ ...d, price: formatIrtInput(e.target.value) }))}
placeholder="0"
/>
</div>
<div className={formStyles.field}>
<label htmlFor="item-discount">{t('invoiceDraft.fieldDiscountedPrice')}</label>
<input
id="item-discount"
inputMode="numeric"
value={itemDraft.discountedPrice}
onChange={(e) =>
setItemDraft((d) => ({ ...d, discountedPrice: formatIrtInput(e.target.value) }))
}
placeholder={t('invoiceDraft.optional')}
/>
</div>
<div className={`${formStyles.field} ${formStyles.span2}`}>
<label htmlFor="item-desc">{t('invoiceDraft.fieldDescription')}</label>
<textarea
id="item-desc"
rows={3}
value={itemDraft.description}
onChange={(e) => setItemDraft((d) => ({ ...d, description: e.target.value }))}
placeholder={t('invoiceDraft.fieldDescriptionPlaceholder')}
/>
</div>
</div>
{itemFormError ? <p className={formStyles.alertError}>{itemFormError}</p> : null}
<div className={formStyles.actionsRow}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => setItemEditorOpen(false)}
disabled={itemSubmitting}
>
{t('invoiceTemplates.cancel')}
</button>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => void handleSaveItem()}
disabled={itemSubmitting}
>
{itemSubmitting
? t('invoiceTemplates.savingItem')
: editingItem
? t('invoiceTemplates.saveItem')
: t('invoiceTemplates.createItem')}
</button>
</div>
</Modal>
<ConfirmDeleteModal
open={!!removeTplTarget}
title={t('invoiceTemplates.deleteTemplateTitle')}
message={
removeTplTarget
? t('invoiceTemplates.deleteTemplateMessage', { name: removeTplTarget.name })
: ''
}
onCancel={() => setRemoveTplTarget(null)}
onConfirm={() => void handleRemoveTpl()}
/>
<ConfirmDeleteModal
open={!!removeItemTarget}
title={t('invoiceTemplates.deleteItemTitle')}
message={
removeItemTarget
? t('invoiceTemplates.deleteItemMessage', { title: removeItemTarget.title })
: ''
}
onCancel={() => setRemoveItemTarget(null)}
onConfirm={() => void handleRemoveItem()}
/>
</main>
)
}
@@ -0,0 +1,531 @@
.tablePanel {
margin-top: 12px;
padding: 0;
background: transparent;
border-radius: var(--radius);
overflow: hidden;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.tableHeader {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: flex-end;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
}
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
/* 12-col feel: name ≈ 4, customer ≈ 2, rest share remaining */
.colName {
width: 33.333%;
}
.colCustomer {
width: 16%;
}
.colIssued {
width: 11%;
}
.colStatus {
width: 10%;
}
.colTotal {
width: 11%;
}
.colLink {
width: 12%;
}
.colActions {
width: 112px;
}
.th,
.td {
padding: 10px 12px;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
min-width: 0;
}
.th {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
}
.td {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.subText {
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemTitle {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tdCustomer {
vertical-align: top;
}
.customerStack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
max-width: 100%;
}
.customerCell {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.customerPhone {
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.recipientHeading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 4px;
}
.recipientPrefix {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.recipientName {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.recipientPhoneChip {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.14);
border: 1px solid rgba(148, 163, 184, 0.28);
font-variant-numeric: tabular-nums;
}
.thActions,
.tdActions {
text-align: end;
white-space: nowrap;
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.actionBtn {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.actionBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.actionBtnDanger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.actionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination {
padding: 12px 16px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
min-height: 38px;
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
box-sizing: border-box;
}
.pagination > div:first-child {
grid-column: 1;
justify-self: start;
}
.pagination > nav {
grid-column: 2;
justify-self: center;
}
.statusChip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid transparent;
transition: filter 0.2s;
font-family: var(--font-ui);
}
.statusChipBtn {
cursor: pointer;
background: none;
}
.statusChipBtn:hover {
filter: brightness(0.95);
}
.statusChipBtn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.statusChipBtn:disabled {
cursor: wait;
opacity: 0.7;
}
.statusOptionList {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.statusOption {
min-height: 32px;
padding: 6px 12px;
font-size: 12px;
}
.statusOptionSelected {
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.status_draft,
.status_issued {
color: #0f172a;
background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.18);
}
.status_approved {
color: #0e7490;
background: rgba(6, 182, 212, 0.14);
border-color: rgba(6, 182, 212, 0.32);
}
.status_paid {
color: #15803d;
background: rgba(34, 197, 94, 0.14);
border-color: rgba(34, 197, 94, 0.32);
}
.status_cancelled {
color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.28);
}
.publicLink {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--primary);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.publicLink:hover {
text-decoration: underline;
}
.linkRow {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.copyLinkBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
background: rgba(var(--primary-rgb) / 0.06);
border: 1px solid rgba(var(--primary-rgb) / 0.14);
flex-shrink: 0;
}
.copyLinkBtn:hover {
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
}
.filterBanner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding: 10px 14px;
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-primary);
}
.filterBannerActions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.filterBannerCancel {
flex-shrink: 0;
margin-inline-start: auto;
padding: 0;
border: none;
background: none;
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
color: var(--primary);
cursor: pointer;
}
.filterBannerCancel:hover {
text-decoration: underline;
}
.filterBannerClear {
font-size: 13px;
font-weight: 600;
color: var(--primary);
}
.filterBannerClear:hover {
text-decoration: underline;
}
.detailMeta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.detailLabel {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 2px;
}
.detailMeta .statusChipBtn {
margin-top: 4px;
}
.detailNotes {
margin: 0 0 14px;
font-size: 13px;
color: var(--text-secondary);
}
.detailItems {
display: flex;
flex-direction: column;
gap: 10px;
}
.detailItem {
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.4);
}
.detailItemTop {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 14px;
}
.detailItemMeta {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.detailItemDesc {
margin: 8px 0 0;
font-size: 13px;
color: var(--text-secondary);
}
.strike {
text-decoration: line-through;
color: var(--text-muted);
font-weight: 500;
}
.blockSection {
margin: 16px 0;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.22);
}
.blockTitle {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.keyPointList {
margin: 0;
padding-inline-start: 18px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 900px) {
.detailMeta {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.detailMeta {
grid-template-columns: 1fr;
}
}
.fab {
position: fixed;
inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px;
z-index: 110;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.fab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (max-width: 768px) {
.fab {
inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px;
width: 52px;
height: 52px;
}
}
+602
View File
@@ -0,0 +1,602 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { Copy, Eye, Pencil, Plus, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Pagination } from '../components/Pagination'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { PageTitle } from '../components/PageTitle'
import { isEmptyRichText } from '../components/RichTextEditor'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
import { getInvoicePublicUrlFallback } from '../lib/config'
import { getActiveBusinessDomain } from '../lib/businessContext'
import { formatCellForDisplay } from '../lib/cellNumber'
import { deleteInvoice, listInvoices, updateInvoiceStatus } from '../services/invoiceService'
import type { BusinessCustomer } from '../services/customerService'
import type { Invoice, InvoiceStatus } from '../types/invoice'
import { formatIrtPrice } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import styles from './InvoicesPage.module.css'
const PAGE_SIZE = 20
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'approved', 'paid', 'cancelled']
function formatDate(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' })
}
function statusLabelKey(status: InvoiceStatus): BusinessMessageKey {
return `invoices.status.${status}` as BusinessMessageKey
}
function statusClass(status: InvoiceStatus) {
return styles[`status_${status}` as keyof typeof styles] ?? styles.status_draft
}
function canEditInvoice(status: InvoiceStatus) {
return status !== 'approved'
}
function allowedStatusOptions(current: InvoiceStatus): InvoiceStatus[] {
if (current !== 'approved') return STATUS_OPTIONS
return STATUS_OPTIONS.filter(
(option) => option === 'approved' || option === 'paid' || option === 'cancelled',
)
}
function customerName(invoice: Invoice): string {
const name = [invoice.user?.firstName, invoice.user?.lastName].filter(Boolean).join(' ').trim()
return name || ''
}
export function InvoicesPage() {
const navigate = useNavigate()
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast()
const filterUserId = searchParams.get('userId') || ''
const presetCustomer = (location.state as { presetCustomer?: BusinessCustomer } | null)
?.presetCustomer
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
const [statusTarget, setStatusTarget] = useState<Invoice | null>(null)
const [statusUpdating, setStatusUpdating] = useState<InvoiceStatus | null>(null)
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
async function reload(signal?: AbortSignal) {
setLoading(true)
setError('')
try {
const list = await listInvoices(
{ page, pageSize: PAGE_SIZE, userId: filterUserId || undefined },
signal,
)
setInvoices(list.items)
setTotalPages(list.totalPages)
setTotal(list.total)
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('invoices.loadError'))
} finally {
setLoading(false)
}
}
useEffect(() => {
const controller = new AbortController()
void reload(controller.signal)
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, filterUserId])
useEffect(() => {
setPage(1)
}, [filterUserId])
const filterCustomerMatch = useMemo(() => {
if (!filterUserId) return undefined
return invoices.find((inv) => inv.userId === filterUserId)
}, [invoices, filterUserId])
const filterCustomerName = useMemo(() => {
if (!filterUserId) return ''
if (presetCustomer && presetCustomer.id === filterUserId) {
const fromPreset = [presetCustomer.firstName, presetCustomer.lastName]
.filter(Boolean)
.join(' ')
.trim()
if (fromPreset) return fromPreset
if (presetCustomer.label?.trim()) return presetCustomer.label.trim()
if (presetCustomer.cellNumber) return formatCellForDisplay(presetCustomer.cellNumber)
}
if (filterCustomerMatch) {
const fromInvoice = customerName(filterCustomerMatch)
if (fromInvoice) return fromInvoice
}
return ''
}, [filterUserId, presetCustomer, filterCustomerMatch])
const issueState =
presetCustomer && presetCustomer.id === filterUserId
? { presetCustomer }
: filterCustomerMatch?.user
? {
presetCustomer: {
id: filterCustomerMatch.user.id,
cellNumber: filterCustomerMatch.user.cell,
firstName: filterCustomerMatch.user.firstName,
lastName: filterCustomerMatch.user.lastName,
email: null,
label: filterCustomerName || filterCustomerMatch.user.cell,
},
}
: undefined
function clearFilter() {
const next = new URLSearchParams(searchParams)
next.delete('userId')
setSearchParams(next, { replace: true, state: null })
}
function openStatusModal(invoice: Invoice) {
setStatusTarget(invoice)
}
async function handleStatusSelect(next: InvoiceStatus) {
if (!statusTarget || statusUpdating) return
if (next === statusTarget.status) {
setStatusTarget(null)
return
}
setStatusUpdating(next)
try {
const updated = await updateInvoiceStatus(statusTarget.id, { status: next })
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
setDetailInvoice((current) => (current?.id === updated.id ? updated : current))
showToast(t('invoices.statusUpdated'), 'success')
setStatusTarget(null)
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoices.statusUpdateError'), 'error')
} finally {
setStatusUpdating(null)
}
}
async function handleRemove() {
if (!removeTarget) return
try {
await deleteInvoice(removeTarget.id)
showToast(t('invoices.removed'), 'success')
setRemoveTarget(null)
if (detailInvoice?.id === removeTarget.id) setDetailInvoice(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : t('invoices.removeError'), 'error')
}
}
function invoicePublicUrl(invoice: Invoice) {
return (
invoice.publicUrl ||
getInvoicePublicUrlFallback(invoice.publicId, getActiveBusinessDomain())
)
}
async function copyPublicLink(invoice: Invoice) {
const url = invoicePublicUrl(invoice)
try {
await navigator.clipboard.writeText(url)
showToast(t('invoices.linkCopied'), 'success')
} catch {
showToast(t('invoices.linkCopyError'), 'error')
}
}
const issueHref = filterUserId ? `/invoices/new?userId=${filterUserId}` : '/invoices/new'
const countLabel =
total === 1 ? t('invoices.countOne', { count: total }) : t('invoices.countMany', { count: total })
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('nav.finance'), href: '/finance' },
{ label: t('title.invoices') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="INVOICES">{t('title.invoices')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('invoices.subtitle')}</p>
</div>
<Link to="/invoices/templates" className={`${formStyles.btn} ${formStyles.btnGhost}`}>
{t('invoices.templatesLink')}
</Link>
</div>
{filterUserId ? (
<div className={styles.filterBanner}>
<span>
{filterCustomerName
? t('invoices.filterBannerNamed', { name: filterCustomerName })
: t('invoices.filterBannerGeneric')}
</span>
<button type="button" className={styles.filterBannerCancel} onClick={clearFilter}>
{t('invoices.filterCancel')}
</button>
</div>
) : null}
{error ? <p className={pageStyles.pageSubtitleContent}>{error}</p> : null}
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.meta}>{loading ? t('invoices.loading') : countLabel}</div>
</div>
<table className={styles.table}>
<colgroup>
<col className={styles.colName} />
<col className={styles.colCustomer} />
<col className={styles.colIssued} />
<col className={styles.colStatus} />
<col className={styles.colTotal} />
<col className={styles.colLink} />
<col className={styles.colActions} />
</colgroup>
<thead>
<tr>
<th className={styles.th}>{t('invoices.col.name')}</th>
<th className={styles.th}>{t('invoices.col.customer')}</th>
<th className={styles.th}>{t('invoices.col.issued')}</th>
<th className={styles.th}>{t('invoices.col.status')}</th>
<th className={styles.th}>{t('invoices.col.total')}</th>
<th className={styles.th}>{t('invoices.col.link')}</th>
<th className={`${styles.th} ${styles.thActions}`}>{t('invoices.col.actions')}</th>
</tr>
</thead>
<tbody>
{!loading && invoices.length === 0 ? (
<tr>
<td className={styles.td} colSpan={7}>
{t('invoices.empty')}
</td>
</tr>
) : null}
{invoices.map((invoice) => {
const itemCount = invoice.items?.length ?? 0
const name = customerName(invoice)
return (
<tr key={invoice.id}>
<td className={styles.td}>
<div className={styles.itemTitle}>{invoice.name || `#${invoice.id}`}</div>
<div className={styles.subText}>
{itemCount === 1
? t('invoices.itemCountOne', { count: itemCount })
: t('invoices.itemCountMany', { count: itemCount })}
</div>
</td>
<td className={`${styles.td} ${styles.tdCustomer}`}>
<div className={styles.customerStack}>
<div className={styles.customerCell}>{name || '—'}</div>
{invoice.user?.cell ? (
<div className={styles.customerPhone} dir="ltr">
{formatCellForDisplay(invoice.user.cell)}
</div>
) : null}
</div>
</td>
<td className={styles.td}>{formatDate(invoice.issuedAt, locale)}</td>
<td className={styles.td}>
<button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(invoice.status)}`}
onClick={() => openStatusModal(invoice)}
title={t('invoices.changeStatus')}
aria-label={t('invoices.changeStatusAria', {
status: t(statusLabelKey(invoice.status)),
})}
>
{t(statusLabelKey(invoice.status))}
</button>
</td>
<td className={styles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
<td className={styles.td}>
<div className={styles.linkRow}>
<a
className={styles.publicLink}
href={invoicePublicUrl(invoice)}
target="_blank"
rel="noreferrer"
title={invoicePublicUrl(invoice)}
>
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
</a>
<Tooltip label={t('invoices.copyLink')}>
<button
type="button"
className={styles.copyLinkBtn}
onClick={() => void copyPublicLink(invoice)}
aria-label={t('invoices.copyLinkAria')}
>
<Copy size={14} />
</button>
</Tooltip>
</div>
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<Tooltip label={t('invoices.view')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => setDetailInvoice(invoice)}
aria-label={t('invoices.viewAria')}
>
<Eye size={16} />
</button>
</Tooltip>
<Tooltip
label={
canEditInvoice(invoice.status)
? t('invoices.edit')
: t('invoices.editDisabledTitle')
}
>
<span>
<button
type="button"
className={styles.actionBtn}
disabled={!canEditInvoice(invoice.status)}
onClick={() => navigate(`/invoices/${invoice.id}/edit`)}
aria-label={
canEditInvoice(invoice.status)
? t('invoices.editAria')
: t('invoices.editDisabledAria')
}
>
<Pencil size={16} />
</button>
</span>
</Tooltip>
<Tooltip label={t('invoices.remove')}>
<button
type="button"
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={() => setRemoveTarget(invoice)}
aria-label={t('invoices.removeAria')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{totalPages > 1 ? (
<div className={styles.pagination}>
<div>{countLabel}</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
) : null}
</div>
</div>
<Modal
open={!!detailInvoice}
title={detailInvoice ? detailInvoice.name || t('invoices.detailFallbackTitle') : ''}
onClose={() => setDetailInvoice(null)}
xl
>
{detailInvoice ? (
<>
<div className={styles.detailMeta}>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.billedTo')}</span>
<strong>{customerName(detailInvoice) || '—'}</strong>
{detailInvoice.user?.cell ? (
<div className={styles.subText} dir="ltr">
{formatCellForDisplay(detailInvoice.user.cell)}
</div>
) : null}
</div>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.status')}</span>
<button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(detailInvoice.status)}`}
onClick={() => openStatusModal(detailInvoice)}
title={t('invoices.changeStatus')}
aria-label={t('invoices.changeStatusAria', {
status: t(statusLabelKey(detailInvoice.status)),
})}
>
{t(statusLabelKey(detailInvoice.status))}
</button>
</div>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.total')}</span>
<strong>{formatIrtPrice(detailInvoice.total ?? 0)}</strong>
</div>
</div>
<div className={styles.detailMeta} style={{ gridTemplateColumns: '1fr' }}>
<div>
<span className={styles.detailLabel}>{t('invoices.detail.link')}</span>
<div className={styles.linkRow}>
<a
className={styles.publicLink}
href={invoicePublicUrl(detailInvoice)}
target="_blank"
rel="noreferrer"
>
{invoicePublicUrl(detailInvoice).replace(/^https?:\/\//, '')}
</a>
<Tooltip label={t('invoices.copyLink')}>
<button
type="button"
className={styles.copyLinkBtn}
onClick={() => void copyPublicLink(detailInvoice)}
aria-label={t('invoices.copyLinkAria')}
>
<Copy size={14} />
</button>
</Tooltip>
</div>
</div>
</div>
{detailInvoice.topText && !isEmptyRichText(detailInvoice.topText) ? (
<div
className={styles.detailNotes}
dangerouslySetInnerHTML={{ __html: detailInvoice.topText }}
/>
) : null}
{detailInvoice.notes ? (
<p className={styles.detailNotes}>
{t('invoices.detail.notes', { notes: detailInvoice.notes })}
</p>
) : null}
<div className={styles.detailItems}>
{(detailInvoice.items ?? []).map((item) => (
<div key={item.id} className={styles.detailItem}>
<div className={styles.detailItemTop}>
<strong>{item.title}</strong>
<span>
{item.discountedPrice !== null && item.discountedPrice < item.price ? (
<>
<span className={styles.strike}>{formatIrtPrice(item.price)}</span>{' '}
{formatIrtPrice(item.discountedPrice)}
</>
) : (
formatIrtPrice(item.price)
)}
</span>
</div>
<div className={styles.detailItemMeta}>
{item.duration ? <span>{item.duration}</span> : null}
{item.worktime ? <span>{item.worktime}</span> : null}
</div>
{item.description ? (
<p className={styles.detailItemDesc}>{item.description}</p>
) : null}
</div>
))}
</div>
{(detailInvoice.keyPoints?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.keyPoints')}</h4>
<ul className={styles.keyPointList}>
{detailInvoice.keyPoints!.map((kp) => (
<li key={kp.id}>{kp.text}</li>
))}
</ul>
</div>
) : null}
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
<div className={styles.detailItems}>
{detailInvoice.accounts!.map((acc) => (
<div key={acc.id} className={styles.detailItem}>
<strong>{acc.bankName}</strong>
<div className={styles.detailItemMeta}>
{acc.accountHolderName ? <span>{acc.accountHolderName}</span> : null}
{acc.cardNumber ? <span dir="ltr">{acc.cardNumber}</span> : null}
{acc.iban ? <span dir="ltr">{acc.iban}</span> : null}
</div>
</div>
))}
</div>
</div>
) : null}
</>
) : null}
</Modal>
<Modal
open={!!statusTarget}
title={t('invoices.changeStatus')}
onClose={() => {
if (!statusUpdating) setStatusTarget(null)
}}
>
{statusTarget ? (
<div className={styles.statusOptionList} role="listbox" aria-label={t('invoices.col.status')}>
{allowedStatusOptions(statusTarget.status).map((option) => {
const selected = statusTarget.status === option
const saving = statusUpdating === option
return (
<button
key={option}
type="button"
role="option"
aria-selected={selected}
className={`${styles.statusChip} ${styles.statusChipBtn} ${styles.statusOption} ${statusClass(option)} ${selected ? styles.statusOptionSelected : ''}`}
disabled={Boolean(statusUpdating)}
onClick={() => void handleStatusSelect(option)}
>
{saving ? t('issueInvoice.saving') : t(statusLabelKey(option))}
</button>
)
})}
</div>
) : null}
</Modal>
<ConfirmDeleteModal
open={!!removeTarget}
title={t('invoices.deleteTitle')}
message={t('invoices.deleteMessage')}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void handleRemove()}
/>
<Tooltip label={t('invoices.issue')}>
<button
type="button"
className={styles.fab}
onClick={() => navigate(issueHref, { state: issueState })}
aria-label={t('invoices.issue')}
>
<Plus size={24} />
</button>
</Tooltip>
</main>
)
}
@@ -0,0 +1,413 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { CustomerSearchSelect } from '../components/CustomerSearchSelect'
import { InvoiceDraftFields } from '../components/InvoiceDraftFields'
import { isEmptyRichText, RichTextEditor } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
createInvoice,
getInvoice,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateInvoice,
} from '../services/invoiceService'
import { listCustomers, type BusinessCustomer } from '../services/customerService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildLineItemsPayload,
draftsFromInvoice,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
type DraftKeyPoint,
type DraftLineItem,
} from '../utils/invoiceDraft'
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import formStyles from '../components/InvoiceForm.module.css'
import styles from './InvoicesPage.module.css'
function effectivePrice(price: number, discountedPrice: number | null | undefined) {
if (discountedPrice !== null && discountedPrice !== undefined && discountedPrice < price) {
return discountedPrice
}
return price
}
function customerFromInvoiceUser(user: {
id: string
firstName: string | null
lastName: string | null
cell: string
}): BusinessCustomer {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return {
id: user.id,
cellNumber: user.cell,
firstName: user.firstName,
lastName: user.lastName,
email: null,
label: name || user.cell,
}
}
export function IssueInvoicePage() {
const { invoiceId = '' } = useParams()
const isEdit = Boolean(invoiceId)
const navigate = useNavigate()
const location = useLocation()
const [searchParams] = useSearchParams()
const { showToast } = useToast()
const t = useT()
const urlUserId = searchParams.get('userId') || ''
const presetCustomer = (location.state as { presetCustomer?: BusinessCustomer } | null)
?.presetCustomer
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [locked, setLocked] = useState(false)
const [billedCustomer, setBilledCustomer] = useState<BusinessCustomer | null>(
presetCustomer && presetCustomer.id === urlUserId ? presetCustomer : null,
)
const [billedUserId, setBilledUserId] = useState(urlUserId)
const customerLocked = isEdit || Boolean(urlUserId)
const [sourceTemplateId, setSourceTemplateId] = useState('')
const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>()
const [invoiceName, setInvoiceName] = useState('')
const [topText, setTopText] = useState('')
const [notes, setNotes] = useState('')
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('')
const listPath = '/invoices'
const createTotal = useMemo(() => {
return draftItems.reduce((sum, item) => {
const price = parseIrtInput(item.price) ?? 0
const discounted = item.discountedPrice.trim() ? parseIrtInput(item.discountedPrice) : null
return sum + effectivePrice(price, discounted)
}, 0)
}, [draftItems])
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
setLocked(false)
try {
const [items, templates] = await Promise.all([
listInvoiceItemTemplates(controller.signal),
listInvoiceTemplates(controller.signal),
])
setItemTemplates(items.items.filter((i) => i.isActive))
setInvoiceTemplates(templates.items.filter((i) => i.isActive))
if (invoiceId) {
const invoice = await getInvoice(invoiceId, controller.signal)
if (invoice.status === 'approved') {
setLocked(true)
setError(t('issueInvoice.approvedLocked'))
}
if (invoice.user) {
setBilledCustomer(customerFromInvoiceUser(invoice.user))
}
setBilledUserId(invoice.userId)
const drafts = draftsFromInvoice(invoice)
setSourceTemplateId('')
setInvoiceTemplateId(invoice.invoiceTemplateId ?? undefined)
setInvoiceName(drafts.name)
setTopText(drafts.topText)
setNotes(drafts.notes)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
} else if (urlUserId && !presetCustomer) {
const listed = await listCustomers({ page: 1, pageSize: 100 }, controller.signal)
const match = listed.items.find((c) => c.id === urlUserId)
if (match) {
setBilledCustomer({
id: match.id,
cellNumber: match.cellNumber,
firstName: match.firstName,
lastName: match.lastName,
email: match.email,
label: match.label,
})
setBilledUserId(match.id)
}
}
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : t('issueInvoice.loadError'))
} finally {
setLoading(false)
}
}
void load()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoiceId])
function resetBlankDraft() {
setSourceTemplateId('')
setInvoiceTemplateId(undefined)
setInvoiceName('')
setTopText('')
setNotes('')
setDraftItems([emptyDraftItem()])
setKeyPoints([])
setAccounts([])
setSelectedItemTemplateId('')
setFormError('')
}
function applyInvoiceTemplate(templateId: string) {
if (locked) return
setSourceTemplateId(templateId)
if (!templateId) {
resetBlankDraft()
return
}
const template = invoiceTemplates.find((tpl) => tpl.id === templateId)
if (!template) return
const drafts = draftsFromInvoiceTemplate(template)
setInvoiceTemplateId(template.id)
setInvoiceName(drafts.name)
setTopText(drafts.topText)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
setFormError('')
}
function handleCustomerChange(customer: BusinessCustomer | null) {
setBilledCustomer(customer)
setBilledUserId(customer?.id ?? '')
}
async function handleSubmit() {
if (locked) return
setFormError('')
if (!billedUserId) {
setFormError(t('issueInvoice.recipientRequired'))
return
}
let items
try {
items = buildLineItemsPayload(draftItems, t)
} catch (err) {
setFormError(err instanceof Error ? err.message : t('issueInvoice.loadError'))
return
}
setSubmitting(true)
try {
if (isEdit) {
await updateInvoice(invoiceId, {
userId: billedUserId,
items,
name: invoiceName.trim() || null,
topText: isEmptyRichText(topText) ? null : topText,
notes: notes.trim() || null,
invoiceTemplateId: invoiceTemplateId ?? null,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
})
showToast(t('issueInvoice.updated'), 'success')
} else {
await createInvoice({
userId: billedUserId,
items,
name: invoiceName.trim() || undefined,
topText: isEmptyRichText(topText) ? undefined : topText,
notes: notes.trim() || undefined,
invoiceTemplateId,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
})
showToast(t('issueInvoice.issued'), 'success')
}
navigate(listPath)
} catch (err) {
setFormError(
err instanceof ApiError
? err.message
: isEdit
? t('issueInvoice.updateError')
: t('issueInvoice.createError'),
)
} finally {
setSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<Link to={listPath} className={formStyles.backLink}>
<ArrowLeft size={16} />
{t('issueInvoice.backToInvoices')}
</Link>
<h2 className={pageStyles.pageTitle}>
{isEdit ? t('title.editInvoice') : t('title.issueInvoice')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit ? t('issueInvoice.subtitleEdit') : t('issueInvoice.subtitleNew')}
</p>
</div>
</div>
{error ? <p className={formStyles.alertError}>{error}</p> : null}
{loading ? <p className={styles.meta}>{t('issueInvoice.loading')}</p> : null}
{!loading && !locked ? (
<section className={formStyles.section}>
<div className={`${formStyles.metaGrid}${isEdit ? ` ${formStyles.metaGridTwo}` : ''}`}>
<div className={formStyles.field}>
<label htmlFor="invoice-recipient">{t('issueInvoice.recipient')}</label>
<CustomerSearchSelect
id="invoice-recipient"
value={billedCustomer}
onChange={handleCustomerChange}
disabled={customerLocked}
/>
</div>
{!isEdit ? (
<div className={formStyles.field}>
<label htmlFor="source-template">{t('issueInvoice.startFromTemplate')}</label>
<select
id="source-template"
value={sourceTemplateId}
onChange={(e) => applyInvoiceTemplate(e.target.value)}
>
<option value="">{t('issueInvoice.blankInvoice')}</option>
{invoiceTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : null}
<div className={formStyles.field}>
<label htmlFor="invoice-name">{t('issueInvoice.name')}</label>
<input
id="invoice-name"
value={invoiceName}
onChange={(e) => setInvoiceName(e.target.value)}
placeholder={t('issueInvoice.namePlaceholder')}
/>
</div>
</div>
{!isEdit && invoiceTemplates.length === 0 ? (
<p className={styles.subText}>
{t('issueInvoice.noTemplatesHint').split('{link}')[0]}
<Link to="/invoices/templates">{t('issueInvoice.noTemplatesLink')}</Link>
{t('issueInvoice.noTemplatesHint').split('{link}')[1]}
</p>
) : null}
<div className={`${formStyles.field} ${formStyles.fullField}`}>
<label>{t('issueInvoice.topText')}</label>
<RichTextEditor
value={topText}
onChange={setTopText}
placeholder={t('issueInvoice.topTextPlaceholder')}
editorMinHeight={88}
/>
</div>
<div className={`${formStyles.field} ${formStyles.fullField}`}>
<label htmlFor="invoice-notes">{t('issueInvoice.notes')}</label>
<input
id="invoice-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('issueInvoice.notesPlaceholder')}
/>
</div>
<InvoiceDraftFields
itemTemplates={itemTemplates}
items={draftItems}
keyPoints={keyPoints}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
onItemsChange={setDraftItems}
onKeyPointsChange={setKeyPoints}
onAccountsChange={setAccounts}
afterItems={
<div className={formStyles.totalRow}>
<span className={formStyles.totalLabel}>{t('issueInvoice.totalLabel')}</span>
<strong className={formStyles.totalValue}>{formatIrtPrice(createTotal)}</strong>
</div>
}
/>
{formError ? <p className={formStyles.alertError}>{formError}</p> : null}
<div className={formStyles.actionsRow}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => navigate(listPath)}
disabled={submitting}
>
{t('issueInvoice.cancel')}
</button>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting
? isEdit
? t('issueInvoice.saving')
: t('issueInvoice.issuing')
: isEdit
? t('issueInvoice.save')
: t('issueInvoice.issue')}
</button>
</div>
</section>
) : null}
{!loading && locked ? (
<div className={formStyles.actionsRow} style={{ justifyContent: 'flex-start' }}>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => navigate(listPath)}
>
{t('issueInvoice.backToInvoices')}
</button>
</div>
) : null}
</main>
)
}
@@ -0,0 +1,34 @@
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import formStyles from '../components/InvoiceForm.module.css'
import pageStyles from '../components/PageContent.module.css'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css'
export function TransactionsPage() {
const t = useT()
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('nav.finance'), href: '/finance' },
{ label: t('title.transactions') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="TRANSACTIONS">{t('title.transactions')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('transactions.subtitle')}</p>
</div>
</div>
<section className={formStyles.section}>
<p className={styles.placeholderLead}>{t('transactions.lead')}</p>
<p className={styles.placeholderNote}>{t('transactions.note')}</p>
</section>
</main>
)
}
@@ -0,0 +1,159 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CreateInvoiceItemTemplatePayload,
CreateInvoicePayload,
CreateInvoiceTemplatePayload,
Invoice,
InvoiceItemTemplate,
InvoiceItemTemplatesResponse,
InvoiceStatus,
InvoiceTemplate,
InvoiceTemplatesListResponse,
InvoicesListResponse,
UpdateInvoiceItemTemplatePayload,
UpdateInvoicePayload,
UpdateInvoiceTemplatePayload,
} from '../types/invoice'
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}${suffix}`
}
export function listInvoiceItemTemplates(signal?: AbortSignal) {
return apiRequest<InvoiceItemTemplatesResponse>(businessPath('/invoice-item-templates'), {
auth: true,
signal,
})
}
export function createInvoiceItemTemplate(payload: CreateInvoiceItemTemplatePayload) {
return apiRequest<InvoiceItemTemplate>(businessPath('/invoice-item-templates'), {
method: 'POST',
auth: true,
body: payload,
})
}
export function updateInvoiceItemTemplate(
templateId: string,
payload: UpdateInvoiceItemTemplatePayload,
) {
return apiRequest<InvoiceItemTemplate>(
businessPath(`/invoice-item-templates/${templateId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export function deleteInvoiceItemTemplate(templateId: string) {
return apiRequest<{ ok: boolean }>(businessPath(`/invoice-item-templates/${templateId}`), {
method: 'DELETE',
auth: true,
})
}
export function listInvoiceTemplates(signal?: AbortSignal) {
return apiRequest<InvoiceTemplatesListResponse>(businessPath('/invoice-templates'), {
auth: true,
signal,
})
}
export function getInvoiceTemplate(templateId: string, signal?: AbortSignal) {
return apiRequest<InvoiceTemplate>(businessPath(`/invoice-templates/${templateId}`), {
auth: true,
signal,
})
}
export function createInvoiceTemplate(payload: CreateInvoiceTemplatePayload) {
return apiRequest<InvoiceTemplate>(businessPath('/invoice-templates'), {
method: 'POST',
auth: true,
body: payload,
})
}
export function updateInvoiceTemplate(templateId: string, payload: UpdateInvoiceTemplatePayload) {
return apiRequest<InvoiceTemplate>(businessPath(`/invoice-templates/${templateId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export function deleteInvoiceTemplate(templateId: string) {
return apiRequest<{ ok: boolean }>(businessPath(`/invoice-templates/${templateId}`), {
method: 'DELETE',
auth: true,
})
}
export interface ListInvoicesParams {
page?: number
pageSize?: number
status?: InvoiceStatus
userId?: string
}
export function listInvoices(params: ListInvoicesParams = {}, signal?: AbortSignal) {
const search = new URLSearchParams()
if (params.page) search.set('page', String(params.page))
if (params.pageSize) search.set('pageSize', String(params.pageSize))
if (params.status) search.set('status', params.status)
if (params.userId) search.set('userId', params.userId)
const qs = search.toString()
return apiRequest<InvoicesListResponse>(businessPath(`/invoices${qs ? `?${qs}` : ''}`), {
auth: true,
signal,
})
}
export function getInvoice(invoiceId: string, signal?: AbortSignal) {
return apiRequest<Invoice>(businessPath(`/invoices/${invoiceId}`), {
auth: true,
signal,
})
}
export function createInvoice(payload: CreateInvoicePayload) {
return apiRequest<Invoice>(businessPath('/invoices'), {
method: 'POST',
auth: true,
body: payload,
})
}
export function updateInvoice(invoiceId: string, payload: UpdateInvoicePayload) {
return apiRequest<Invoice>(businessPath(`/invoices/${invoiceId}`), {
method: 'PUT',
auth: true,
body: payload,
})
}
export function updateInvoiceStatus(
invoiceId: string,
payload: { status: InvoiceStatus; notes?: string },
) {
return apiRequest<Invoice>(businessPath(`/invoices/${invoiceId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export function deleteInvoice(invoiceId: string) {
return apiRequest<{ ok: boolean }>(businessPath(`/invoices/${invoiceId}`), {
method: 'DELETE',
auth: true,
})
}
+210
View File
@@ -0,0 +1,210 @@
export type InvoiceStatus = 'draft' | 'issued' | 'approved' | 'paid' | 'cancelled'
export interface InvoiceItemTemplate {
id: string
ownerScope: 'platform' | 'business'
businessId: string | null
title: string
duration: string | null
worktime: string | null
description: string | null
price: number
discountedPrice: number | null
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
}
export interface InvoiceItem {
id: string
invoiceId: string
templateId: string | null
title: string
duration: string | null
worktime: string | null
description: string | null
price: number
discountedPrice: number | null
sortOrder: number
}
export interface InvoiceKeyPoint {
id: string
text: string
sortOrder: number
}
export interface InvoiceAccount {
id: string
bankName: string
accountHolderName: string | null
cardNumber: string | null
iban: string | null
sortOrder: number
}
export interface Invoice {
id: string
publicId: string
businessId: string
userId: string
ownerScope: 'platform' | 'business'
issuerBusinessId: string | null
status: InvoiceStatus
name: string | null
topText: string | null
notes: string | null
invoiceTemplateId: string | null
publicUrl: string | null
issuedBy: string | null
issuedAt: string
createdAt: string
updatedAt: string
business?: {
id: string
name: string
nameFa: string | null
}
user?: {
id: string
firstName: string | null
lastName: string | null
cell: string
}
issuer?: {
id: string
firstName: string | null
lastName: string | null
} | null
items?: InvoiceItem[]
keyPoints?: InvoiceKeyPoint[]
accounts?: InvoiceAccount[]
subtotal?: number
total?: number
}
export interface InvoiceItemInput {
templateId?: string
title: string
duration?: string
worktime?: string
description?: string
price: number
discountedPrice?: number | null
}
export interface InvoiceKeyPointInput {
text: string
}
export interface InvoiceAccountInput {
bankName: string
accountHolderName?: string
cardNumber?: string
iban?: string
}
/** Business dashboards always bill a specific customer — `userId` is required. */
export interface CreateInvoicePayload {
userId: string
items: InvoiceItemInput[]
name?: string
topText?: string
notes?: string
invoiceTemplateId?: string
keyPoints?: InvoiceKeyPointInput[]
accounts?: InvoiceAccountInput[]
status?: InvoiceStatus
}
export type UpdateInvoicePayload = {
userId?: string
items: InvoiceItemInput[]
name?: string | null
topText?: string | null
notes?: string | null
invoiceTemplateId?: string | null
keyPoints?: InvoiceKeyPointInput[]
accounts?: InvoiceAccountInput[]
}
export interface InvoiceTemplateItem {
id: string
itemTemplateId: string | null
title: string
duration: string | null
worktime: string | null
description: string | null
price: number
discountedPrice: number | null
sortOrder: number
}
export interface InvoiceTemplate {
id: string
ownerScope: 'platform' | 'business'
businessId: string | null
name: string
topText: string | null
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
items: InvoiceTemplateItem[]
keyPoints: InvoiceKeyPoint[]
accounts: InvoiceAccount[]
}
export interface InvoiceTemplateItemInput {
itemTemplateId?: string
title: string
duration?: string
worktime?: string
description?: string
price: number
discountedPrice?: number | null
}
export interface CreateInvoiceTemplatePayload {
name: string
topText?: string
items: InvoiceTemplateItemInput[]
keyPoints?: InvoiceKeyPointInput[]
accounts?: InvoiceAccountInput[]
sortOrder?: number
}
export type UpdateInvoiceTemplatePayload = Partial<CreateInvoiceTemplatePayload> & {
isActive?: boolean
}
export interface InvoiceItemTemplatesResponse {
items: InvoiceItemTemplate[]
}
export interface InvoiceTemplatesListResponse {
items: InvoiceTemplate[]
}
export interface InvoicesListResponse {
items: Invoice[]
page: number
pageSize: number
total: number
totalPages: number
}
export interface CreateInvoiceItemTemplatePayload {
title: string
duration?: string
worktime?: string
description?: string
price: number
discountedPrice?: number | null
sortOrder?: number
}
export type UpdateInvoiceItemTemplatePayload = Partial<CreateInvoiceItemTemplatePayload> & {
isActive?: boolean
}
+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',
})
}
@@ -6,9 +6,9 @@
position: sticky;
top: 0;
z-index: 50;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: var(--header-bg, var(--glass-bg));
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
border-bottom: 1px solid var(--glass-border);
isolation: isolate;
transform: translateZ(0);
+6
View File
@@ -11,6 +11,11 @@
--bg-gradient-mid: #eef0f3;
--bg-gradient-end: #e8eaee;
--glass-bg: rgba(255, 255, 255, 0.72);
--header-bg: color-mix(
in srgb,
color-mix(in srgb, var(--primary-light) 28%, #ffffff) 92%,
transparent
);
--glass-border: rgba(148, 163, 184, 0.28);
--glass-shadow: 0 8px 32px rgba(15, 23, 42, 0.08);
--surface: rgba(255, 255, 255, 0.82);
@@ -32,6 +37,7 @@ html[data-theme='dark'] {
--bg-gradient-end: #1a1d23;
--glass-bg: rgba(45, 49, 57, 0.9);
--header-bg: color-mix(in srgb, #2a2d34 92%, transparent);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
--modal-overlay-bg: rgba(0, 0, 0, 0.6);
@@ -6,9 +6,9 @@
position: sticky;
top: 0;
z-index: 50;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: var(--header-bg, var(--glass-bg-strong, var(--glass-bg)));
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
border-bottom: 1px solid var(--glass-border);
isolation: isolate;
transform: translateZ(0);
+5
View File
@@ -12,6 +12,11 @@
--bg-gradient-end: #f4f7ff;
--glass-bg: rgba(255, 255, 255, 0.42);
--glass-bg-strong: rgba(255, 255, 255, 0.62);
--header-bg: color-mix(
in srgb,
color-mix(in srgb, var(--primary-light) 42%, #ffffff) 90%,
transparent
);
--glass-border: rgba(255, 255, 255, 0.75);
--glass-shadow: 0 8px 32px rgba(30, 58, 138, 0.1);
--primary: #0a1628;
@@ -193,6 +193,7 @@ export function BusinessInvoicesPage() {
<thead>
<tr>
<th className={tableStyles.th}>Name</th>
<th className={tableStyles.th}>Billed user</th>
<th className={tableStyles.th}>Issued</th>
<th className={tableStyles.th}>Status</th>
<th className={tableStyles.th}>Total</th>
@@ -203,7 +204,7 @@ export function BusinessInvoicesPage() {
<tbody>
{!loading && invoices.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={6}>
<td className={tableStyles.td} colSpan={7}>
No invoices yet for this business.
</td>
</tr>
@@ -216,6 +217,15 @@ export function BusinessInvoicesPage() {
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
</div>
</td>
<td className={tableStyles.td}>
{invoice.user
? [invoice.user.firstName, invoice.user.lastName].filter(Boolean).join(' ') ||
invoice.user.cell
: '—'}
{invoice.user?.cell ? (
<div className={tableStyles.subText}>{invoice.user.cell}</div>
) : null}
</td>
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
<td className={tableStyles.td}>
<button
+13
View File
@@ -48,6 +48,7 @@ export interface Invoice {
id: string
publicId: string
businessId: string
userId: string
ownerScope: 'platform' | 'business'
issuerBusinessId: string | null
status: InvoiceStatus
@@ -65,6 +66,12 @@ export interface Invoice {
name: string
nameFa: string | null
}
user?: {
id: string
firstName: string | null
lastName: string | null
cell: string
}
issuer?: {
id: string
firstName: string | null
@@ -85,6 +92,10 @@ export type PublicInvoice = {
topText: string | null
issuedAt: string
business?: Invoice['business']
user?: {
firstName: string | null
lastName: string | null
}
items?: InvoiceItem[]
keyPoints?: InvoiceKeyPoint[]
accounts?: InvoiceAccount[]
@@ -115,6 +126,7 @@ export interface InvoiceAccountInput {
}
export interface CreateInvoicePayload {
userId?: string
items: InvoiceItemInput[]
name?: string
topText?: string
@@ -126,6 +138,7 @@ export interface CreateInvoicePayload {
}
export type UpdateInvoicePayload = {
userId?: string
items: InvoiceItemInput[]
name?: string | null
topText?: string | null