diff --git a/apps/business/src/types/invoice.ts b/apps/business/src/types/invoice.ts
index 9dffa13..ce8d4e0 100644
--- a/apps/business/src/types/invoice.ts
+++ b/apps/business/src/types/invoice.ts
@@ -35,6 +35,12 @@ export interface InvoiceKeyPoint {
sortOrder: number
}
+export interface InvoicePaymentMethod {
+ id: string
+ text: string
+ sortOrder: number
+}
+
export interface InvoiceAccount {
id: string
bankName: string
@@ -79,6 +85,7 @@ export interface Invoice {
} | null
items?: InvoiceItem[]
keyPoints?: InvoiceKeyPoint[]
+ paymentMethods?: InvoicePaymentMethod[]
accounts?: InvoiceAccount[]
subtotal?: number
total?: number
@@ -98,6 +105,10 @@ export interface InvoiceKeyPointInput {
text: string
}
+export interface InvoicePaymentMethodInput {
+ text: string
+}
+
export interface InvoiceAccountInput {
bankName: string
accountHolderName?: string
@@ -114,6 +125,7 @@ export interface CreateInvoicePayload {
notes?: string
invoiceTemplateId?: string
keyPoints?: InvoiceKeyPointInput[]
+ paymentMethods?: InvoicePaymentMethodInput[]
accounts?: InvoiceAccountInput[]
status?: InvoiceStatus
}
@@ -126,6 +138,7 @@ export type UpdateInvoicePayload = {
notes?: string | null
invoiceTemplateId?: string | null
keyPoints?: InvoiceKeyPointInput[]
+ paymentMethods?: InvoicePaymentMethodInput[]
accounts?: InvoiceAccountInput[]
}
@@ -153,6 +166,7 @@ export interface InvoiceTemplate {
updatedAt: string
items: InvoiceTemplateItem[]
keyPoints: InvoiceKeyPoint[]
+ paymentMethods: InvoicePaymentMethod[]
accounts: InvoiceAccount[]
}
@@ -171,6 +185,7 @@ export interface CreateInvoiceTemplatePayload {
topText?: string
items: InvoiceTemplateItemInput[]
keyPoints?: InvoiceKeyPointInput[]
+ paymentMethods?: InvoicePaymentMethodInput[]
accounts?: InvoiceAccountInput[]
sortOrder?: number
}
diff --git a/apps/business/src/utils/invoiceDraft.ts b/apps/business/src/utils/invoiceDraft.ts
index 135f15b..53376dc 100644
--- a/apps/business/src/utils/invoiceDraft.ts
+++ b/apps/business/src/utils/invoiceDraft.ts
@@ -5,6 +5,7 @@ import type {
InvoiceItemInput,
InvoiceItemTemplate,
InvoiceKeyPointInput,
+ InvoicePaymentMethodInput,
InvoiceTemplate,
InvoiceTemplateItemInput,
} from '../types/invoice'
@@ -40,6 +41,8 @@ export type DraftLineItem = {
export type DraftKeyPoint = { key: string; text: string }
+export type DraftPaymentMethod = { key: string; text: string }
+
export type DraftAccount = {
key: string
bankName: string
@@ -68,6 +71,10 @@ export function emptyDraftKeyPoint(): DraftKeyPoint {
return { key: newKey(), text: '' }
}
+export function emptyDraftPaymentMethod(): DraftPaymentMethod {
+ return { key: newKey(), text: '' }
+}
+
export function emptyDraftAccount(): DraftAccount {
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
}
@@ -93,6 +100,7 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
topText: string
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
+ paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[]
} {
return {
@@ -118,6 +126,10 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
template.keyPoints.length > 0
? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text }))
: [],
+ paymentMethods:
+ (template.paymentMethods?.length ?? 0) > 0
+ ? template.paymentMethods.map((pm) => ({ key: newKey(), text: pm.text }))
+ : [],
accounts:
template.accounts.length > 0
? template.accounts.map((acc) => ({
@@ -145,6 +157,7 @@ export function draftsFromInvoice(invoice: {
discountedPrice: number | null
}>
keyPoints?: Array<{ text: string }>
+ paymentMethods?: Array<{ text: string }>
accounts?: Array<{
bankName: string
accountHolderName: string | null
@@ -157,6 +170,7 @@ export function draftsFromInvoice(invoice: {
notes: string
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
+ paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[]
} {
return {
@@ -183,6 +197,10 @@ export function draftsFromInvoice(invoice: {
(invoice.keyPoints?.length ?? 0) > 0
? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text }))
: [],
+ paymentMethods:
+ (invoice.paymentMethods?.length ?? 0) > 0
+ ? invoice.paymentMethods!.map((pm) => ({ key: newKey(), text: pm.text }))
+ : [],
accounts:
(invoice.accounts?.length ?? 0) > 0
? invoice.accounts!.map((acc) => ({
@@ -248,6 +266,15 @@ export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointI
.map((text) => ({ text }))
}
+export function buildPaymentMethodsPayload(
+ methods: DraftPaymentMethod[],
+): InvoicePaymentMethodInput[] {
+ return methods
+ .map((m) => m.text.trim())
+ .filter(Boolean)
+ .map((text) => ({ text }))
+}
+
export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] {
return accounts
.map((acc) => ({
diff --git a/apps/customer/src/components/CategorySearchSelect.tsx b/apps/customer/src/components/CategorySearchSelect.tsx
index c9b9433..b462908 100644
--- a/apps/customer/src/components/CategorySearchSelect.tsx
+++ b/apps/customer/src/components/CategorySearchSelect.tsx
@@ -1,9 +1,6 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { ChevronDown, Search, X } from 'lucide-react'
-import { useLocale } from '@meshkee/dashboard-ui'
-import { useT } from '../i18n/useT'
+import { useMemo } from 'react'
import type { UserProductCategoryOption } from '../services/userProductsService'
-import styles from './CategorySearchSelect.module.css'
+import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
export interface FlatCategoryOption extends UserProductCategoryOption {
depth: number
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
dropdownVariant?: 'overlay' | 'inFlow'
}
-function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
- if (isFa) {
- return category.nameFa?.trim() || category.name
- }
- return category.name || category.nameFa?.trim() || ''
-}
-
+/** @deprecated Prefer CategoryTreeSelect; kept for callers that flatten options. */
export function flattenCategoryTree(
categories: UserProductCategoryOption[],
): FlatCategoryOption[] {
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
}
for (const list of byParent.values()) {
- list.sort((a, b) => {
- const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
- return aLabel
- })
+ list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
}
const result: FlatCategoryOption[] = []
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
function walk(parentId: string | null, depth: number) {
const children = byParent.get(parentId) ?? []
for (const child of children) {
+ if (!ids.has(child.id)) continue
result.push({ ...child, depth })
walk(child.id, depth + 1)
}
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
walk(null, 0)
+ const rooted = new Set(result.map((item) => item.id))
for (const category of categories) {
- if (category.parentId && !ids.has(category.parentId)) {
- if (!result.some((item) => item.id === category.id)) {
- result.push({ ...category, depth: 0 })
- walk(category.id, 1)
- }
+ if (!rooted.has(category.id)) {
+ result.push({ ...category, depth: 0 })
}
}
return result
}
-function filterCategoryTree(
- flat: FlatCategoryOption[],
- query: string,
-): FlatCategoryOption[] {
- const q = query.trim().toLowerCase()
- if (!q) return flat
-
- const byId = new Map(flat.map((item) => [item.id, item]))
- const matchedIds = new Set
()
-
- for (const item of flat) {
- const nameEn = item.name.toLowerCase()
- const nameFa = (item.nameFa || '').toLowerCase()
- if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
- matchedIds.add(item.id)
- let parentId = item.parentId
- while (parentId) {
- matchedIds.add(parentId)
- parentId = byId.get(parentId)?.parentId ?? null
- }
- }
- }
-
- return flat.filter((item) => matchedIds.has(item.id))
-}
-
-function isLastChildInGroup(items: FlatCategoryOption[], index: number): boolean {
- const current = items[index]
- if (current.depth === 0) return false
- const next = items[index + 1]
- return !next || next.depth < current.depth
-}
-
-function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) {
- if (depth === 0) return null
-
- return (
-
- {Array.from({ length: depth }, (_, level) => {
- const isBranchLevel = level === depth - 1
- return (
-
- )
- })}
-
- )
+function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
+ return options.map((option) => ({
+ id: option.id,
+ parentId: option.parentId,
+ nameEn: option.name,
+ nameFa: option.nameFa?.trim() || '',
+ }))
}
+/** Marketplace categories — same collapsible tree UX as business dashboards. */
export function CategorySearchSelect({
options,
value,
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
allowEmpty = false,
dropdownVariant = 'overlay',
}: CategorySearchSelectProps) {
- const t = useT()
- const { locale } = useLocale()
- const isFa = locale === 'fa'
- const [open, setOpen] = useState(false)
- const [query, setQuery] = useState('')
- const containerRef = useRef(null)
-
- const flat = useMemo(() => flattenCategoryTree(options), [options])
- const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
-
- const selected = flat.find((option) => option.id === value)
- const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
- const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCategory')
-
- 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])
-
- function selectOption(nextId: string) {
- onChange(nextId)
- setOpen(false)
- setQuery('')
- }
-
- const dropdownClassName = [
- styles.dropdown,
- dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay,
- ]
- .filter(Boolean)
- .join(' ')
+ const categories = useMemo(() => toTreeItems(options), [options])
return (
-
-
-
- {
- setQuery(e.target.value)
- if (!open) setOpen(true)
- }}
- onFocus={() => {
- if (!disabled) setOpen(true)
- }}
- autoComplete="off"
- role="combobox"
- aria-expanded={open}
- aria-autocomplete="list"
- />
- {allowEmpty && value && !open && !disabled ? (
-
- ) : null}
-
-
-
- {open && !disabled ? (
-
- {filtered.length === 0 ? (
- - {t('myProducts.fields.noCategories')}
- ) : (
- filtered.map((option, index) => {
- const isLastChild = isLastChildInGroup(filtered, index)
- return (
- -
-
-
- )
- })
- )}
-
- ) : null}
-
+
)
}
diff --git a/apps/customer/src/components/CategoryTree.module.css b/apps/customer/src/components/CategoryTree.module.css
new file mode 100644
index 0000000..7fe963f
--- /dev/null
+++ b/apps/customer/src/components/CategoryTree.module.css
@@ -0,0 +1,18 @@
+.childrenWrap {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.28s ease;
+}
+
+.childrenWrap.expanded {
+ grid-template-rows: 1fr;
+}
+
+.childrenInner {
+ overflow: hidden;
+ margin-inline-start: 28px;
+ margin-bottom: 10px;
+ padding-inline-start: 22px;
+ padding-inline-end: 8px;
+ border-inline-start: 1px solid rgba(148, 163, 184, 0.55);
+}
diff --git a/apps/customer/src/components/CategoryTreeSelect.module.css b/apps/customer/src/components/CategoryTreeSelect.module.css
new file mode 100644
index 0000000..414d1c2
--- /dev/null
+++ b/apps/customer/src/components/CategoryTreeSelect.module.css
@@ -0,0 +1,218 @@
+.wrapper {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.wrapperOpen {
+ z-index: 130;
+}
+
+.inputWrap {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-height: var(--field-height);
+ padding: 0 var(--field-padding-x);
+ background: var(--surface);
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ transition: border-color 0.2s, box-shadow 0.2s;
+ box-sizing: border-box;
+}
+
+.inputWrapOpen,
+.inputWrap:focus-within {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
+}
+
+.searchIcon {
+ flex-shrink: 0;
+ color: var(--text-muted);
+}
+
+.input {
+ flex: 1;
+ min-width: 0;
+ padding: var(--field-padding-y) 0;
+ font-size: var(--field-font-size);
+ font-family: var(--font-ui);
+ line-height: 1.4;
+ color: var(--text-primary);
+ background: transparent;
+ border: none;
+ outline: none;
+}
+
+.input::placeholder {
+ font-family: var(--font-ui);
+ color: var(--text-muted);
+ opacity: 1;
+}
+
+.clearBtn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ color: var(--text-muted);
+ transition: background 0.2s, color 0.2s;
+}
+
+.clearBtn:hover {
+ background: rgba(148, 163, 184, 0.2);
+ color: var(--text-secondary);
+}
+
+.fieldChevron {
+ flex-shrink: 0;
+ color: var(--text-muted);
+ transition: transform 0.2s ease;
+}
+
+.fieldChevronOpen {
+ transform: rotate(180deg);
+}
+
+.panel {
+ max-height: min(320px, 50vh);
+ overflow-y: auto;
+ padding: 4px;
+ background: var(--elevated-surface);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14);
+}
+
+.panelOverlay {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ z-index: 120;
+}
+
+.panelInFlow {
+ position: static;
+ margin-top: 6px;
+ max-height: none;
+ overflow: visible;
+ z-index: auto;
+}
+
+.noneOption {
+ width: 100%;
+ padding: 8px 10px;
+ margin-bottom: 4px;
+ border-radius: 8px;
+ font-size: var(--field-font-size);
+ font-family: var(--font-ui);
+ text-align: start;
+ color: var(--text-secondary);
+ transition: background 0.15s, color 0.15s;
+}
+
+.noneOption:hover {
+ background: rgba(var(--primary-rgb) / 0.08);
+ color: var(--text-primary);
+}
+
+.noneOptionSelected {
+ background: rgba(var(--primary-rgb) / 0.12);
+ color: var(--primary);
+ font-weight: 500;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ width: 100%;
+ padding: 6px 8px;
+ border-radius: 8px;
+ text-align: start;
+ color: var(--text-primary);
+ transition: background 0.15s;
+}
+
+.row:hover {
+ background: rgba(var(--primary-rgb) / 0.08);
+}
+
+.rowSelected {
+ background: rgba(var(--primary-rgb) / 0.12);
+ color: var(--primary);
+}
+
+.rowSelected:hover {
+ background: rgba(var(--primary-rgb) / 0.16);
+}
+
+.toggleBtn {
+ flex-shrink: 0;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ color: var(--text-muted);
+ background: transparent;
+ cursor: pointer;
+ outline: none;
+ transition: background 0.15s, color 0.15s;
+}
+
+.toggleBtn:hover {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.toggleSpacer {
+ flex-shrink: 0;
+ width: 24px;
+ height: 24px;
+}
+
+.selectBtn {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 2px 0;
+ text-align: start;
+ color: inherit;
+ font-size: var(--field-font-size);
+ font-family: var(--font-ui);
+ background: transparent;
+ cursor: pointer;
+ outline: none;
+}
+
+.name {
+ font-weight: 500;
+}
+
+.nameSecondary {
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--text-muted);
+}
+
+.rowSelected .nameSecondary {
+ color: inherit;
+ opacity: 0.75;
+}
+
+.noResults {
+ padding: 16px 12px;
+ font-size: 13px;
+ color: var(--text-muted);
+ text-align: center;
+}
diff --git a/apps/customer/src/components/CategoryTreeSelect.tsx b/apps/customer/src/components/CategoryTreeSelect.tsx
new file mode 100644
index 0000000..46fd975
--- /dev/null
+++ b/apps/customer/src/components/CategoryTreeSelect.tsx
@@ -0,0 +1,357 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { ChevronDown, Minus, Plus, Search, X } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
+import { useT } from '../i18n/useT'
+import { Tooltip } from './Tooltip'
+import treeStyles from './CategoryTree.module.css'
+import styles from './CategoryTreeSelect.module.css'
+
+/** Minimal category shape for the shared tree picker. */
+export interface CategoryTreeItem {
+ id: string
+ parentId: string | null
+ nameEn: string
+ nameFa: string
+}
+
+interface CategoryTreeSelectProps {
+ categories: CategoryTreeItem[]
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ disabled?: boolean
+ id?: string
+ /** Show empty option + clear control. */
+ allowEmpty?: boolean
+ /** Label for the empty option when `allowEmpty` is true. */
+ emptyLabel?: string
+ /** Overlay floats over content; inFlow expands the parent. */
+ dropdownVariant?: 'overlay' | 'inFlow'
+}
+
+function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
+ return categories
+ .filter((category) => category.parentId === parentId)
+ .sort((a, b) => a.nameEn.localeCompare(b.nameEn))
+}
+
+function getAncestorIds(categories: CategoryTreeItem[], id: string) {
+ const byId = new Map(categories.map((category) => [category.id, category]))
+ const ids: string[] = []
+ let current = byId.get(id)
+
+ while (current?.parentId) {
+ ids.push(current.parentId)
+ current = byId.get(current.parentId)
+ }
+
+ return ids
+}
+
+function categoryLabel(category: CategoryTreeItem, isFa: boolean) {
+ if (isFa) {
+ return category.nameFa
+ ? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
+ : category.nameEn
+ }
+ return category.nameEn
+ ? `${category.nameEn}${category.nameFa ? ` / ${category.nameFa}` : ''}`
+ : category.nameFa
+}
+
+function categoryMatches(category: CategoryTreeItem, query: string) {
+ const q = query.trim().toLowerCase()
+ if (!q) return true
+ return (
+ category.nameEn.toLowerCase().includes(q) ||
+ category.nameFa.toLowerCase().includes(q) ||
+ category.nameFa.includes(query.trim())
+ )
+}
+
+function visibleIdsForQuery(
+ categories: CategoryTreeItem[],
+ query: string,
+): Set | null {
+ const q = query.trim()
+ if (!q) return null
+
+ const matched = categories.filter((category) => categoryMatches(category, q))
+ const visible = new Set()
+
+ for (const category of matched) {
+ visible.add(category.id)
+ for (const ancestorId of getAncestorIds(categories, category.id)) {
+ visible.add(ancestorId)
+ }
+ }
+
+ return visible
+}
+
+function TreeNodes({
+ categories,
+ parentId,
+ value,
+ expandedIds,
+ visibleIds,
+ forceExpanded,
+ onToggle,
+ onSelect,
+}: {
+ categories: CategoryTreeItem[]
+ parentId: string | null
+ value: string
+ expandedIds: Set
+ visibleIds: Set | null
+ forceExpanded: boolean
+ onToggle: (id: string) => void
+ onSelect: (id: string) => void
+}) {
+ const t = useT()
+ const { locale } = useLocale()
+ const isFa = locale === 'fa'
+ const nodes = getChildren(categories, parentId).filter(
+ (category) => !visibleIds || visibleIds.has(category.id),
+ )
+
+ return (
+ <>
+ {nodes.map((category) => {
+ const childCount = getChildren(categories, category.id).some(
+ (child) => !visibleIds || visibleIds.has(child.id),
+ )
+ const expanded = forceExpanded || expandedIds.has(category.id)
+ const selected = value === category.id
+ const primary = isFa ? category.nameFa || category.nameEn : category.nameEn
+ const secondary = isFa
+ ? category.nameFa && category.nameEn && category.nameFa !== category.nameEn
+ ? category.nameEn
+ : ''
+ : category.nameFa
+
+ return (
+
+
+ {childCount ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {childCount ? (
+
+ ) : null}
+
+ )
+ })}
+ >
+ )
+}
+
+export function CategoryTreeSelect({
+ categories,
+ value,
+ onChange,
+ placeholder,
+ disabled = false,
+ id,
+ allowEmpty = false,
+ emptyLabel,
+ dropdownVariant = 'overlay',
+}: CategoryTreeSelectProps) {
+ const t = useT()
+ const { locale } = useLocale()
+ const isFa = locale === 'fa'
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [expandedIds, setExpandedIds] = useState>(() => new Set())
+ const containerRef = useRef(null)
+
+ const selected = categories.find((category) => category.id === value)
+ const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
+ const searchPlaceholder = placeholder ?? t('categories.select.search')
+ const emptyOptionLabel = emptyLabel ?? t('categories.select.none')
+
+ const visibleIds = useMemo(
+ () => visibleIdsForQuery(categories, query),
+ [categories, query],
+ )
+
+ useEffect(() => {
+ setOpen(false)
+ setQuery('')
+ setExpandedIds(new Set())
+ }, [categories])
+
+ 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 (!value) return
+ setExpandedIds((prev) => {
+ const next = new Set(prev)
+ for (const ancestorId of getAncestorIds(categories, value)) {
+ next.add(ancestorId)
+ }
+ return next
+ })
+ }, [categories, value])
+
+ function handleToggle(toggleId: string) {
+ setExpandedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(toggleId)) next.delete(toggleId)
+ else next.add(toggleId)
+ return next
+ })
+ }
+
+ function selectOption(nextId: string) {
+ onChange(nextId)
+ setOpen(false)
+ setQuery('')
+ }
+
+ const searching = query.trim().length > 0
+ const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
+
+ return (
+
+
+
+ {
+ setQuery(e.target.value)
+ if (!open) setOpen(true)
+ }}
+ onClick={() => {
+ if (!disabled) setOpen(true)
+ }}
+ autoComplete="off"
+ role="combobox"
+ aria-expanded={open}
+ aria-autocomplete="list"
+ />
+ {allowEmpty && value && !open && !disabled ? (
+
+ ) : null}
+
+
+
+ {open && !disabled ? (
+
+ {allowEmpty ? (
+
+ ) : null}
+ {!hasVisible ? (
+
{t('categories.select.empty')}
+ ) : (
+
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts
index 01e3875..104c690 100644
--- a/apps/customer/src/i18n/messages.ts
+++ b/apps/customer/src/i18n/messages.ts
@@ -396,6 +396,13 @@ const en = {
'otp.signingIn': 'Signing in...',
'otp.error.signIn': 'Unable to sign in with SMS code.',
+ 'categories.tooltip.expand': 'Expand',
+ 'categories.tooltip.collapse': 'Collapse',
+ 'categories.select.search': 'Search categories…',
+ 'categories.select.none': '— No category —',
+ 'categories.select.empty': 'No categories found',
+ 'categories.select.clear': 'Clear selection',
+
'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s',
'common.resend': 'Resend SMS code',
@@ -800,6 +807,13 @@ const fa: Record = {
'otp.signingIn': 'در حال ورود...',
'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
+ 'categories.tooltip.expand': 'باز کردن',
+ 'categories.tooltip.collapse': 'بستن',
+ 'categories.select.search': 'جستجوی دستهبندیها…',
+ 'categories.select.none': '— بدون دستهبندی —',
+ 'categories.select.empty': 'دستهبندیای یافت نشد',
+ 'categories.select.clear': 'پاک کردن انتخاب',
+
'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
'common.resend': 'ارسال مجدد کد پیامکی',
diff --git a/apps/super-admin/src/components/InvoiceDraftFields.tsx b/apps/super-admin/src/components/InvoiceDraftFields.tsx
index 7082efe..24c7476 100644
--- a/apps/super-admin/src/components/InvoiceDraftFields.tsx
+++ b/apps/super-admin/src/components/InvoiceDraftFields.tsx
@@ -1,12 +1,13 @@
import { Pencil, Plus, X } from 'lucide-react'
import type { InvoiceItemTemplate } from '../types/invoice'
import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice'
-import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
+import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
import {
draftItemFromItemTemplate,
emptyDraftAccount,
emptyDraftItem,
emptyDraftKeyPoint,
+ emptyDraftPaymentMethod,
} from '../utils/invoiceDraft'
import tableStyles from '../pages/BusinessesPage.module.css'
import styles from '../pages/BusinessInvoicesPage.module.css'
@@ -17,15 +18,23 @@ function focusKeyPointInput(index: number) {
el?.select()
}
+function focusPaymentMethodInput(index: number) {
+ const el = document.querySelector(`input[data-payment-method-index="${index}"]`)
+ el?.focus()
+ el?.select()
+}
+
type Props = {
itemTemplates: InvoiceItemTemplate[]
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
+ paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[]
selectedItemTemplateId: string
onSelectedItemTemplateId: (id: string) => void
onItemsChange: (items: DraftLineItem[]) => void
onKeyPointsChange: (points: DraftKeyPoint[]) => void
+ onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
onAccountsChange: (accounts: DraftAccount[]) => void
showItemTemplatePicker?: boolean
}
@@ -34,11 +43,13 @@ export function InvoiceDraftFields({
itemTemplates,
items,
keyPoints,
+ paymentMethods,
accounts,
selectedItemTemplateId,
onSelectedItemTemplateId,
onItemsChange,
onKeyPointsChange,
+ onPaymentMethodsChange,
onAccountsChange,
showItemTemplatePicker = true,
}: Props) {
@@ -239,6 +250,67 @@ export function InvoiceDraftFields({
)}
+
Account numbers
diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
index 0ae81ce..ec45d55 100644
--- a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
+++ b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
@@ -429,6 +429,17 @@ export function BusinessInvoicesPage() {
) : null}
+ {(detailInvoice.paymentMethods?.length ?? 0) > 0 ? (
+
+
Payment methods
+
+ {detailInvoice.paymentMethods!.map((pm) => (
+ - {pm.text}
+ ))}
+
+
+ ) : null}
+
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
Accounts
diff --git a/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx b/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx
index 31a5a05..c54cea6 100644
--- a/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx
+++ b/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx
@@ -15,12 +15,14 @@ import type { InvoiceItemTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
+ buildPaymentMethodsPayload,
buildTemplateItemsPayload,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
type DraftKeyPoint,
type DraftLineItem,
+ type DraftPaymentMethod,
} from '../utils/invoiceDraft'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
@@ -40,6 +42,7 @@ export function InvoiceTemplateEditorPage() {
const [topText, setTopText] = useState('')
const [items, setItems] = useState
([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState([])
+ const [paymentMethods, setPaymentMethods] = useState([])
const [accounts, setAccounts] = useState([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -62,6 +65,7 @@ export function InvoiceTemplateEditorPage() {
setTopText(drafts.topText)
setItems(drafts.items)
setKeyPoints(drafts.keyPoints)
+ setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts)
}
} catch (err) {
@@ -95,6 +99,7 @@ export function InvoiceTemplateEditorPage() {
topText: isEmptyRichText(topText) ? undefined : topText,
items: lineItems,
keyPoints: buildKeyPointsPayload(keyPoints),
+ paymentMethods: buildPaymentMethodsPayload(paymentMethods),
accounts: buildAccountsPayload(accounts),
}
@@ -127,7 +132,7 @@ export function InvoiceTemplateEditorPage() {
{isEdit ? 'Edit invoice template' : 'Add invoice template'}
- Define name, top text, line items, key points, and bank accounts for reuse when issuing
+ Define name, top text, line items, key points, payment methods, and bank accounts for reuse when issuing
invoices.
@@ -164,11 +169,13 @@ export function InvoiceTemplateEditorPage() {
itemTemplates={itemTemplates}
items={items}
keyPoints={keyPoints}
+ paymentMethods={paymentMethods}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
onItemsChange={setItems}
onKeyPointsChange={setKeyPoints}
+ onPaymentMethodsChange={setPaymentMethods}
onAccountsChange={setAccounts}
/>
diff --git a/apps/super-admin/src/pages/IssueInvoicePage.tsx b/apps/super-admin/src/pages/IssueInvoicePage.tsx
index e4245ab..55145d3 100644
--- a/apps/super-admin/src/pages/IssueInvoicePage.tsx
+++ b/apps/super-admin/src/pages/IssueInvoicePage.tsx
@@ -18,12 +18,14 @@ import {
buildAccountsPayload,
buildKeyPointsPayload,
buildLineItemsPayload,
+ buildPaymentMethodsPayload,
draftsFromInvoice,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
type DraftKeyPoint,
type DraftLineItem,
+ type DraftPaymentMethod,
} from '../utils/invoiceDraft'
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
@@ -58,6 +60,7 @@ export function IssueInvoicePage() {
const [notes, setNotes] = useState('')
const [draftItems, setDraftItems] = useState
([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState([])
+ const [paymentMethods, setPaymentMethods] = useState([])
const [accounts, setAccounts] = useState([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -107,6 +110,7 @@ export function IssueInvoicePage() {
setNotes(drafts.notes)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
+ setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
}
@@ -130,6 +134,7 @@ export function IssueInvoicePage() {
setNotes('')
setDraftItems([emptyDraftItem()])
setKeyPoints([])
+ setPaymentMethods([])
setAccounts([])
setSelectedItemTemplateId('')
setFormError('')
@@ -150,6 +155,7 @@ export function IssueInvoicePage() {
setTopText(drafts.topText)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
+ setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
setFormError('')
@@ -176,6 +182,7 @@ export function IssueInvoicePage() {
notes: notes.trim() || null,
invoiceTemplateId: invoiceTemplateId ?? null,
keyPoints: buildKeyPointsPayload(keyPoints),
+ paymentMethods: buildPaymentMethodsPayload(paymentMethods),
accounts: buildAccountsPayload(accounts),
})
showToast('Invoice updated.', 'success')
@@ -187,6 +194,7 @@ export function IssueInvoicePage() {
notes: notes.trim() || undefined,
invoiceTemplateId,
keyPoints: buildKeyPointsPayload(keyPoints),
+ paymentMethods: buildPaymentMethodsPayload(paymentMethods),
accounts: buildAccountsPayload(accounts),
})
showToast('Invoice issued.', 'success')
@@ -300,11 +308,13 @@ export function IssueInvoicePage() {
itemTemplates={itemTemplates}
items={draftItems}
keyPoints={keyPoints}
+ paymentMethods={paymentMethods}
accounts={accounts}
selectedItemTemplateId={selectedItemTemplateId}
onSelectedItemTemplateId={setSelectedItemTemplateId}
onItemsChange={setDraftItems}
onKeyPointsChange={setKeyPoints}
+ onPaymentMethodsChange={setPaymentMethods}
onAccountsChange={setAccounts}
/>
diff --git a/apps/super-admin/src/pages/PublicInvoicePage.tsx b/apps/super-admin/src/pages/PublicInvoicePage.tsx
index b9b669f..e5f416a 100644
--- a/apps/super-admin/src/pages/PublicInvoicePage.tsx
+++ b/apps/super-admin/src/pages/PublicInvoicePage.tsx
@@ -23,6 +23,7 @@ const COPY: Record<
subtotal: string
total: string
keyPoints: string
+ paymentMethods: string
accounts: string
card: string
iban: string
@@ -45,6 +46,7 @@ const COPY: Record<
subtotal: 'Subtotal',
total: 'Total',
keyPoints: 'Key points',
+ paymentMethods: 'Payment methods',
accounts: 'Account numbers',
card: 'Card',
iban: 'IBAN',
@@ -66,6 +68,7 @@ const COPY: Record<
subtotal: 'جمع جزء',
total: 'مبلغ کل',
keyPoints: 'نکات کلیدی',
+ paymentMethods: 'نحوه ی پرداخت',
accounts: 'شماره حسابها',
card: 'کارت',
iban: 'شبا',
@@ -240,6 +243,17 @@ export function PublicInvoicePage() {
) : null}
+ {(invoice.paymentMethods?.length ?? 0) > 0 ? (
+
+ {t.paymentMethods}
+
+ {invoice.paymentMethods!.map((pm) => (
+ - {pm.text}
+ ))}
+
+
+ ) : null}
+
{(invoice.accounts?.length ?? 0) > 0 ? (
{t.accounts}
diff --git a/apps/super-admin/src/types/invoice.ts b/apps/super-admin/src/types/invoice.ts
index fc44795..c727ed6 100644
--- a/apps/super-admin/src/types/invoice.ts
+++ b/apps/super-admin/src/types/invoice.ts
@@ -35,6 +35,12 @@ export interface InvoiceKeyPoint {
sortOrder: number
}
+export interface InvoicePaymentMethod {
+ id: string
+ text: string
+ sortOrder: number
+}
+
export interface InvoiceAccount {
id: string
bankName: string
@@ -79,6 +85,7 @@ export interface Invoice {
} | null
items?: InvoiceItem[]
keyPoints?: InvoiceKeyPoint[]
+ paymentMethods?: InvoicePaymentMethod[]
accounts?: InvoiceAccount[]
subtotal?: number
total?: number
@@ -102,6 +109,7 @@ export type PublicInvoice = {
}
items?: InvoiceItem[]
keyPoints?: InvoiceKeyPoint[]
+ paymentMethods?: InvoicePaymentMethod[]
accounts?: InvoiceAccount[]
subtotal?: number
total?: number
@@ -122,6 +130,10 @@ export interface InvoiceKeyPointInput {
text: string
}
+export interface InvoicePaymentMethodInput {
+ text: string
+}
+
export interface InvoiceAccountInput {
bankName: string
accountHolderName?: string
@@ -137,6 +149,7 @@ export interface CreateInvoicePayload {
notes?: string
invoiceTemplateId?: string
keyPoints?: InvoiceKeyPointInput[]
+ paymentMethods?: InvoicePaymentMethodInput[]
accounts?: InvoiceAccountInput[]
status?: InvoiceStatus
}
@@ -149,6 +162,7 @@ export type UpdateInvoicePayload = {
notes?: string | null
invoiceTemplateId?: string | null
keyPoints?: InvoiceKeyPointInput[]
+ paymentMethods?: InvoicePaymentMethodInput[]
accounts?: InvoiceAccountInput[]
}
@@ -176,6 +190,7 @@ export interface InvoiceTemplate {
updatedAt: string
items: InvoiceTemplateItem[]
keyPoints: InvoiceKeyPoint[]
+ paymentMethods: InvoicePaymentMethod[]
accounts: InvoiceAccount[]
}
@@ -194,6 +209,7 @@ export interface CreateInvoiceTemplatePayload {
topText?: string
items: InvoiceTemplateItemInput[]
keyPoints?: InvoiceKeyPointInput[]
+ paymentMethods?: InvoicePaymentMethodInput[]
accounts?: InvoiceAccountInput[]
sortOrder?: number
}
diff --git a/apps/super-admin/src/utils/invoiceDraft.ts b/apps/super-admin/src/utils/invoiceDraft.ts
index 5ac6875..17191f1 100644
--- a/apps/super-admin/src/utils/invoiceDraft.ts
+++ b/apps/super-admin/src/utils/invoiceDraft.ts
@@ -4,6 +4,7 @@ import type {
InvoiceItemInput,
InvoiceItemTemplate,
InvoiceKeyPointInput,
+ InvoicePaymentMethodInput,
InvoiceTemplate,
InvoiceTemplateItemInput,
} from '../types/invoice'
@@ -21,6 +22,8 @@ export type DraftLineItem = {
export type DraftKeyPoint = { key: string; text: string }
+export type DraftPaymentMethod = { key: string; text: string }
+
export type DraftAccount = {
key: string
bankName: string
@@ -49,6 +52,10 @@ export function emptyDraftKeyPoint(): DraftKeyPoint {
return { key: newKey(), text: '' }
}
+export function emptyDraftPaymentMethod(): DraftPaymentMethod {
+ return { key: newKey(), text: '' }
+}
+
export function emptyDraftAccount(): DraftAccount {
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
}
@@ -74,6 +81,7 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
topText: string
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
+ paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[]
} {
return {
@@ -99,6 +107,10 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
template.keyPoints.length > 0
? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text }))
: [],
+ paymentMethods:
+ (template.paymentMethods?.length ?? 0) > 0
+ ? template.paymentMethods.map((pm) => ({ key: newKey(), text: pm.text }))
+ : [],
accounts:
template.accounts.length > 0
? template.accounts.map((acc) => ({
@@ -126,6 +138,7 @@ export function draftsFromInvoice(invoice: {
discountedPrice: number | null
}>
keyPoints?: Array<{ text: string }>
+ paymentMethods?: Array<{ text: string }>
accounts?: Array<{
bankName: string
accountHolderName: string | null
@@ -138,6 +151,7 @@ export function draftsFromInvoice(invoice: {
notes: string
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
+ paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[]
} {
return {
@@ -164,6 +178,10 @@ export function draftsFromInvoice(invoice: {
(invoice.keyPoints?.length ?? 0) > 0
? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text }))
: [],
+ paymentMethods:
+ (invoice.paymentMethods?.length ?? 0) > 0
+ ? invoice.paymentMethods!.map((pm) => ({ key: newKey(), text: pm.text }))
+ : [],
accounts:
(invoice.accounts?.length ?? 0) > 0
? invoice.accounts!.map((acc) => ({
@@ -223,6 +241,15 @@ export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointI
.map((text) => ({ text }))
}
+export function buildPaymentMethodsPayload(
+ methods: DraftPaymentMethod[],
+): InvoicePaymentMethodInput[] {
+ return methods
+ .map((m) => m.text.trim())
+ .filter(Boolean)
+ .map((text) => ({ text }))
+}
+
export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] {
return accounts
.map((acc) => ({
diff --git a/packages/dashboard-core/src/utils/documentTitle.ts b/packages/dashboard-core/src/utils/documentTitle.ts
index c7180ec..a2f1ee7 100644
--- a/packages/dashboard-core/src/utils/documentTitle.ts
+++ b/packages/dashboard-core/src/utils/documentTitle.ts
@@ -4,7 +4,7 @@ export interface DashboardDocumentTitleParts {
pageLabels?: string[]
}
-/** Pattern: `{businessName} - {dashboardName} · {page}` */
+/** Pattern: `{dashboardName} · {page} - {businessName}` */
export function formatDashboardDocumentTitle({
businessName,
dashboardName,
@@ -13,12 +13,10 @@ export function formatDashboardDocumentTitle({
const business = businessName.trim() || 'Store'
const dashboard = dashboardName.trim()
const pages = pageLabels.map((label) => label.trim()).filter(Boolean)
+ const pagePart =
+ pages.length === 0 ? dashboard : `${dashboard} · ${pages.join(' · ')}`
- if (pages.length === 0) {
- return `${business} - ${dashboard}`
- }
-
- return `${business} - ${dashboard} · ${pages.join(' · ')}`
+ return `${pagePart} - ${business}`
}
export interface RouteTitleRule {