Unify category tree pickers and prefer free-text technical AI fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-21 20:02:05 +03:30
co-authored by Cursor
parent 838e84056e
commit 8465a29ce1
40 changed files with 1192 additions and 586 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
@@ -8,8 +8,7 @@ import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import { CategoryTreeSelect } from './CategoryTreeSelect'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddProductByAiModal.module.css'
@@ -41,7 +40,6 @@ export function AddProductByAiModal({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = useMemo(() => flattenCategories(categories), [categories])
const promptDir = language === 'fa' ? 'rtl' : 'ltr'
useEffect(() => {
@@ -162,13 +160,13 @@ export function AddProductByAiModal({
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={modalStyles.field}>
<label htmlFor="ai-product-category">{t('products.ai.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('products.ai.categoryPlaceholder')}
allowEmpty={false}
dropdownVariant="inFlow"
disabled={isSubmitting || isLoadingCategories}
/>
</div>
+2 -1
View File
@@ -142,7 +142,8 @@ export function BrandModal({
id="nameEn"
name="nameEn"
type="text"
dir={isFa ? 'rtl' : 'ltr'}
dir="ltr"
lang="en"
placeholder={t('brands.modal.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
@@ -93,6 +93,7 @@
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.field label {
+14 -9
View File
@@ -4,8 +4,8 @@ import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import { getDescendantIds } from '../utils/categories'
import { CategoryTreeSelect } from './CategoryTreeSelect'
import { SeoMetaFields } from './SeoMetaFields'
import styles from './CategoryModal.module.css'
@@ -88,9 +88,12 @@ export function CategoryModal({
})
}
const parentOptions = flattenCategories(
categories.filter((category) => category.id !== editingCategory?.id),
)
const parentCategories = editingCategory
? categories.filter(
(category) =>
!getDescendantIds(categories, editingCategory.id).includes(category.id),
)
: categories
return createPortal(
<div
@@ -123,12 +126,13 @@ export function CategoryModal({
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label>{t('categories.modal.parent')}</label>
<SearchableSelect
options={parentOptions}
<CategoryTreeSelect
categories={parentCategories}
value={parentId}
onChange={setParentId}
placeholder={t('categories.select.search')}
dropdownVariant="inFlow"
allowEmpty
disabled={isSubmitting}
/>
</div>
@@ -154,7 +158,8 @@ export function CategoryModal({
id="nameEn"
name="nameEn"
type="text"
dir={isFa ? 'rtl' : 'ltr'}
dir="ltr"
lang="en"
placeholder={t('categories.modal.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
@@ -75,6 +75,8 @@
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
direction: ltr;
unicode-bidi: isolate;
}
.separator {
@@ -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 user-product category API shape. */
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<string>()
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 (
<div className={styles.treeGutters} aria-hidden>
{Array.from({ length: depth }, (_, level) => {
const isBranchLevel = level === depth - 1
return (
<span
key={level}
className={[
styles.treeGutter,
isBranchLevel ? styles.treeGutterBranch : styles.treeGutterContinue,
isBranchLevel && isLast ? styles.treeGutterBranchLast : '',
]
.filter(Boolean)
.join(' ')}
/>
)
})}
</div>
)
function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
return options.map((option) => ({
id: option.id,
parentId: option.parentId,
nameEn: option.name,
nameFa: option.nameFa?.trim() || '',
}))
}
/** User-product marketplace categories — same tree UX as CategoryTreeSelect. */
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<HTMLDivElement>(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('customerProducts.form.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 (
<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={selected ? 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"
/>
{allowEmpty && value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('customerProducts.form.fields.clearCategory')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={dropdownClassName} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('customerProducts.form.fields.noCategories')}</li>
) : (
filtered.map((option, index) => {
const isLastChild = isLastChildInGroup(filtered, index)
return (
<li
key={option.id}
className={[
styles.optionItem,
isLastChild ? styles.optionItemGroupEnd : '',
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
className={[
styles.option,
option.depth === 0 ? styles.optionParent : styles.optionChild,
value === option.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
{option.depth > 0 ? (
<CategoryTreeGutters depth={option.depth} isLast={isLastChild} />
) : null}
<span className={styles.optionBody}>
<span className={styles.optionLabel}>
{categoryLabel(option, isFa)}
</span>
{!isFa && option.nameFa ? (
<span className={styles.optionSecondary}>{option.nameFa}</span>
) : null}
{isFa && option.name && option.name !== option.nameFa ? (
<span className={styles.optionSecondary} dir="ltr">
{option.name}
</span>
) : null}
</span>
</button>
</li>
)
})
)}
</ul>
) : null}
</div>
<CategoryTreeSelect
id={id}
categories={categories}
value={value}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
allowEmpty={allowEmpty}
dropdownVariant={dropdownVariant}
/>
)
}
@@ -5,6 +5,10 @@
min-height: 0;
}
.wrapperOpen {
z-index: 130;
}
.inputWrap {
display: flex;
align-items: center;
@@ -75,14 +79,8 @@
}
.panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 120;
max-height: min(320px, 50vh);
overflow-y: auto;
margin-top: 0;
padding: 4px;
background: var(--elevated-surface);
border: 1px solid var(--glass-border);
@@ -90,6 +88,14 @@
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;
@@ -98,6 +104,29 @@
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;
@@ -135,7 +164,7 @@
background: transparent;
cursor: pointer;
outline: none;
transition: background 0.15s, color 0.15s, transform 0.2s;
transition: background 0.15s, color 0.15s;
}
.toggleBtn:hover {
@@ -149,23 +178,6 @@
height: 24px;
}
.chevron {
display: inline-flex;
transition: transform 0.2s ease;
}
:global([dir='rtl']) .chevron {
transform: scaleX(-1);
}
.chevronOpen {
transform: rotate(90deg);
}
:global([dir='rtl']) .chevronOpen {
transform: scaleX(-1) rotate(90deg);
}
.selectBtn {
flex: 1;
min-width: 0;
@@ -1,25 +1,54 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react'
import { ChevronDown, Minus, Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { Category } from '../types/category'
import { getAncestorIds, getChildren } from '../utils/categories'
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: Category[]
categories: CategoryTreeItem[]
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
id?: string
/** Overlay floats over content; inFlow expands the parent (use inside modals). */
/** 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 categoryLabel(category: Category, isFa: boolean) {
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}` : ''}`
@@ -30,7 +59,7 @@ function categoryLabel(category: Category, isFa: boolean) {
: category.nameFa
}
function categoryMatches(category: Category, query: string) {
function categoryMatches(category: CategoryTreeItem, query: string) {
const q = query.trim().toLowerCase()
if (!q) return true
return (
@@ -40,7 +69,10 @@ function categoryMatches(category: Category, query: string) {
)
}
function visibleIdsForQuery(categories: Category[], query: string): Set<string> | null {
function visibleIdsForQuery(
categories: CategoryTreeItem[],
query: string,
): Set<string> | null {
const q = query.trim()
if (!q) return null
@@ -67,7 +99,7 @@ function TreeNodes({
onToggle,
onSelect,
}: {
categories: Category[]
categories: CategoryTreeItem[]
parentId: string | null
value: string
expandedIds: Set<string>
@@ -114,9 +146,7 @@ function TreeNodes({
aria-expanded={expanded}
onClick={() => onToggle(category.id)}
>
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
<ChevronRight size={16} />
</span>
{expanded ? <Minus size={14} /> : <Plus size={14} />}
</button>
</Tooltip>
) : (
@@ -127,9 +157,19 @@ function TreeNodes({
className={styles.selectBtn}
onClick={() => onSelect(category.id)}
>
<span className={styles.name}>{primary}</span>
<span
className={styles.name}
dir={isFa ? 'rtl' : 'ltr'}
lang={isFa ? 'fa' : 'en'}
>
{primary}
</span>
{secondary ? (
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
<span
className={styles.nameSecondary}
dir={isFa ? 'ltr' : 'rtl'}
lang={isFa ? 'en' : 'fa'}
>
· {secondary}
</span>
) : null}
@@ -165,6 +205,8 @@ export function CategoryTreeSelect({
placeholder,
disabled = false,
id,
allowEmpty = false,
emptyLabel,
dropdownVariant = 'overlay',
}: CategoryTreeSelectProps) {
const t = useT()
@@ -178,6 +220,7 @@ export function CategoryTreeSelect({
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),
@@ -213,17 +256,17 @@ export function CategoryTreeSelect({
})
}, [categories, value])
function handleToggle(id: string) {
function handleToggle(toggleId: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
if (next.has(toggleId)) next.delete(toggleId)
else next.add(toggleId)
return next
})
}
function selectOption(id: string) {
onChange(id)
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
@@ -232,7 +275,11 @@ export function CategoryTreeSelect({
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
return (
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
<div
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
@@ -254,7 +301,7 @@ export function CategoryTreeSelect({
aria-expanded={open}
aria-autocomplete="list"
/>
{value && !open && !disabled ? (
{allowEmpty && value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
@@ -275,11 +322,20 @@ export function CategoryTreeSelect({
<div
className={[
styles.panel,
dropdownVariant === 'inFlow' ? styles.panelInFlow : '',
dropdownVariant === 'inFlow' ? styles.panelInFlow : styles.panelOverlay,
]
.filter(Boolean)
.join(' ')}
>
{allowEmpty ? (
<button
type="button"
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
onClick={() => selectOption('')}
>
{emptyOptionLabel}
</button>
) : null}
{!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p>
) : (
@@ -140,7 +140,7 @@
}
.chartDay {
gap: 1px;
gap: 2px;
}
.chartMonth {
@@ -172,19 +172,26 @@
justify-content: center;
width: 100%;
height: 180px;
gap: 2px;
gap: 3px;
}
.barWrap {
position: relative;
display: flex;
flex: 0 0 auto;
flex: 1 1 0;
align-items: flex-end;
justify-content: center;
width: 100%;
min-width: 0;
height: 100%;
z-index: 1;
}
.chartMonth .barWrap {
flex: 0 0 auto;
width: auto;
}
.barWrap:hover,
.barWrap:focus-within {
z-index: 1000;
@@ -223,20 +230,19 @@
.bar {
position: relative;
flex: 1 1 0;
width: 100%;
max-width: 8px;
min-width: 2px;
flex: none;
width: 80%;
max-width: 14px;
min-width: 8px;
border-radius: 3px 3px 1px 1px;
transition: height 0.3s ease;
cursor: default;
}
.chartMonth .bar {
flex: none;
width: 10px;
width: 12px;
max-width: none;
min-width: 10px;
min-width: 12px;
border-radius: 4px 4px 2px 2px;
}
@@ -246,8 +252,8 @@
}
.barMonth {
width: 10px;
max-width: 10px;
width: 12px;
max-width: 12px;
}
.label {
@@ -274,7 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
value={categoryId}
onChange={setCategoryId}
placeholder={t('website.linkGenerator.pickCategory')}
dropdownVariant="inFlow"
allowEmpty={false}
/>
)}
</div>
@@ -3,12 +3,13 @@ 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 type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
import {
draftItemFromItemTemplate,
emptyDraftAccount,
emptyDraftItem,
emptyDraftKeyPoint,
emptyDraftPaymentMethod,
} from '../utils/invoiceDraft'
import formStyles from './InvoiceForm.module.css'
import styles from './InvoiceDraftFields.module.css'
@@ -19,15 +20,23 @@ function focusKeyPointInput(index: number) {
el?.select()
}
function focusPaymentMethodInput(index: number) {
const el = document.querySelector<HTMLInputElement>(`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
/** Rendered after line items (before key points / accounts), e.g. totals row. */
@@ -38,11 +47,13 @@ export function InvoiceDraftFields({
itemTemplates,
items,
keyPoints,
paymentMethods,
accounts,
selectedItemTemplateId,
onSelectedItemTemplateId,
onItemsChange,
onKeyPointsChange,
onPaymentMethodsChange,
onAccountsChange,
showItemTemplatePicker = true,
afterItems,
@@ -248,6 +259,67 @@ export function InvoiceDraftFields({
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.paymentMethods')}</h4>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
>
<Plus size={16} />
{t('invoiceDraft.addPaymentMethod')}
</button>
</div>
{paymentMethods.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noPaymentMethods')}</p>
) : (
<div className={styles.repeatStack}>
{paymentMethods.map((method, index) => (
<div key={method.key} className={styles.repeatRow}>
<div className={`${formStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
<input
data-payment-method-index={index}
value={method.text}
onChange={(e) =>
onPaymentMethodsChange(
paymentMethods.map((m) =>
m.key === method.key ? { ...m, text: e.target.value } : m,
),
)
}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
e.preventDefault()
const next = index + 1
if (next < paymentMethods.length) {
focusPaymentMethodInput(next)
return
}
onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])
window.setTimeout(() => focusPaymentMethodInput(next), 0)
}}
placeholder={t('invoiceDraft.paymentMethodPlaceholder')}
aria-label={t('invoiceDraft.paymentMethodAria', { index: index + 1 })}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() =>
onPaymentMethodsChange(paymentMethods.filter((m) => m.key !== method.key))
}
aria-label={t('invoiceDraft.removePaymentMethod')}
title={t('invoiceDraft.removePaymentMethod')}
>
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
@@ -76,7 +76,7 @@ export function PortfolioCard({
</h3>
) : null}
{titleEn ? (
<p className={cardStyles.titleEn} lang="en" dir={isFa ? 'rtl' : 'ltr'}>
<p className={cardStyles.titleEn} lang="en" dir="ltr">
{titleEn}
</p>
) : null}
@@ -227,6 +227,8 @@
.optionEn {
font-weight: 500;
text-align: start;
direction: ltr;
unicode-bidi: isolate;
}
.optionEn[dir='rtl'] {
@@ -195,13 +195,17 @@ export function SearchableSelect({
{cat.nameEn ? (
<>
<span className={styles.optionSep}>·</span>
<span className={styles.optionEn}>{cat.nameEn}</span>
<span className={styles.optionEn} dir="ltr" lang="en">
{cat.nameEn}
</span>
</>
) : null}
</>
) : (
<>
<span className={styles.optionEn}>{cat.nameEn}</span>
<span className={styles.optionEn} dir="ltr" lang="en">
{cat.nameEn}
</span>
{cat.nameFa ? (
<>
<span className={styles.optionSep}>·</span>
@@ -10,18 +10,22 @@ export function buildTechnicalFormAiPrompt(categoryName: string) {
return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
برای هر فیلد این موارد را مشخص کن:
- برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، رنگ‌ها)
- نوع ورودی: text، textarea، select، multi_select یا multi_value
- برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، ظرفیت)
- نوع ورودی: ترجیحاً text (یا textarea / multi_value در صورت نیاز)
- الزامی بودن یا نبودن
- برای select / multi_select: فهرست گزینه‌ها به فارسی
- multi_value یعنی لیست تکراری از مقادیر متنی آزاد (بدون گزینه)، مناسب برای ویژگی‌های چندتایی
مهم — نوع ورودی:
- ترجیحاً از ورودی text استفاده کن تا فروشنده بتواند مقدار دلخواه را آزادانه وارد کند.
- از select و multi_select حتی‌المقدور استفاده نکن؛ فقط وقتی گزینه‌ها واقعاً ثابت و استانداردند (مثل بله/خیر).
- اگر بین text و select مردد هستی، text را انتخاب کن.
همیشه در انتهای فرم یک فیلد «سایر ویژگی‌ها» از نوع multi_value در نظر گرفته می‌شود؛ خودت آن را نساز.
اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را هم به‌صورت text اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتی‌متر یا کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن.
روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.
برچسب‌ها و گزینه‌ها را به فارسی بنویس.
برچسب‌ها را به فارسی بنویس.
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز می‌کند و فرم را بر اساس مشخصات واقعی آن می‌سازد).`
}
+16 -2
View File
@@ -739,6 +739,7 @@ const en = {
'seo.meta.descriptionPlaceholder': 'Meta description for search results',
'categories.select.search': 'Search categories...',
'categories.select.none': '— No parent (top-level category) —',
'categories.select.noneCategory': '— No category —',
'categories.select.empty': 'No categories found',
'categories.select.clear': 'Clear selection',
@@ -2424,6 +2425,12 @@ const en = {
'invoiceDraft.keyPointPlaceholder': 'e.g. Payment due within 7 days',
'invoiceDraft.keyPointAria': 'Key point {index}',
'invoiceDraft.removeKeyPoint': 'Remove key point',
'invoiceDraft.paymentMethods': 'Payment methods',
'invoiceDraft.addPaymentMethod': 'Add payment method',
'invoiceDraft.noPaymentMethods': 'No payment methods yet.',
'invoiceDraft.paymentMethodPlaceholder': 'e.g. Cash, card transfer, or installments',
'invoiceDraft.paymentMethodAria': 'Payment method {index}',
'invoiceDraft.removePaymentMethod': 'Remove payment method',
'invoiceDraft.accounts': 'Account numbers',
'invoiceDraft.addAccount': 'Add account',
'invoiceDraft.noAccounts': 'No bank accounts yet.',
@@ -2491,7 +2498,7 @@ const en = {
'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.',
'Define name, top text, line items, key points, payment methods, and bank accounts for reuse when issuing invoices.',
'invoiceTemplateEditor.name': 'Name',
'invoiceTemplateEditor.namePlaceholder': 'e.g. Standard website package',
'invoiceTemplateEditor.topText': 'Top text',
@@ -3356,6 +3363,7 @@ const fa: Record<MessageKey, string> = {
'seo.meta.descriptionPlaceholder': 'توضیحات متا برای نتایج جستجو',
'categories.select.search': 'جستجوی دسته‌بندی‌ها...',
'categories.select.none': '— بدون دسته والد (دسته سطح بالا) —',
'categories.select.noneCategory': '— بدون دسته‌بندی —',
'categories.select.empty': 'دسته‌بندی‌ای یافت نشد',
'categories.select.clear': 'پاک کردن انتخاب',
@@ -5043,6 +5051,12 @@ const fa: Record<MessageKey, string> = {
'invoiceDraft.keyPointPlaceholder': 'مثلاً پرداخت ظرف ۷ روز',
'invoiceDraft.keyPointAria': 'نکته کلیدی {index}',
'invoiceDraft.removeKeyPoint': 'حذف نکته کلیدی',
'invoiceDraft.paymentMethods': 'نحوه ی پرداخت',
'invoiceDraft.addPaymentMethod': 'افزودن نحوه پرداخت',
'invoiceDraft.noPaymentMethods': 'هنوز نحوه پرداختی ثبت نشده است.',
'invoiceDraft.paymentMethodPlaceholder': 'مثلاً نقد، کارت به کارت یا اقساط',
'invoiceDraft.paymentMethodAria': 'نحوه پرداخت {index}',
'invoiceDraft.removePaymentMethod': 'حذف نحوه پرداخت',
'invoiceDraft.accounts': 'شماره حساب‌ها',
'invoiceDraft.addAccount': 'افزودن حساب',
'invoiceDraft.noAccounts': 'هنوز حساب بانکی‌ای ثبت نشده است.',
@@ -5110,7 +5124,7 @@ const fa: Record<MessageKey, string> = {
'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور',
'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور',
'invoiceTemplateEditor.subtitle':
'نام، متن بالا، آیتم‌ها، نکات کلیدی و حساب‌های بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.',
'نام، متن بالا، آیتم‌ها، نکات کلیدی، نحوه ی پرداخت و حساب‌های بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.',
'invoiceTemplateEditor.name': 'نام',
'invoiceTemplateEditor.namePlaceholder': 'مثلاً بسته استاندارد سایت',
'invoiceTemplateEditor.topText': 'متن بالای فاکتور',
+6 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category'
import type { BlogPostType } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -61,8 +60,7 @@ export function AddNewBlogPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
const selectedAspect =
const selectedAspect =
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
useEffect(() => {
@@ -273,11 +271,13 @@ export function AddNewBlogPage() {
<div className={`${styles.field} ${styles.col6Span}`}>
<label>{t('blog.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('blog.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category'
import type { VideoProvider } from '../types/instruction'
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -63,8 +62,7 @@ export function AddNewInstructionPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
const selectedAspect =
const selectedAspect =
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
useEffect(() => {
@@ -257,11 +255,13 @@ export function AddNewInstructionPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('instruction.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('instruction.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
@@ -27,7 +27,6 @@ import {
updatePortfolio,
} from '../services/portfolioService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -70,8 +69,7 @@ export function AddNewPortfolioPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
const selectedAspect =
const selectedAspect =
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
useEffect(() => {
@@ -277,11 +275,13 @@ export function AddNewPortfolioPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('portfolio.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('portfolio.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
@@ -28,7 +29,6 @@ import {
fillProductFieldByAi,
} from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
@@ -60,12 +60,11 @@ export function AddNewProductPage() {
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
const [isAiRunning, setIsAiRunning] = useState(false)
const categoryOptions = flattenCategories(categories)
const selectedCategoryName = useMemo(() => {
const match = categoryOptions.find((item) => item.id === categoryId)
const match = categories.find((item) => item.id === categoryId)
if (!match) return ''
return match.nameFa?.trim() || match.nameEn || ''
}, [categoryOptions, categoryId])
}, [categories, categoryId])
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
@@ -293,8 +292,8 @@ export function AddNewProductPage() {
<div className={styles.topFields}>
<div className={`${styles.field} ${styles.fieldCategory}`}>
<label>{t('products.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('products.form.categoryPlaceholder')}
+6 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category'
import type { VideoProvider } from '../types/video'
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -55,8 +54,7 @@ export function AddNewVideoPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
@@ -218,11 +216,13 @@ export function AddNewVideoPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('video.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('video.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
@@ -28,7 +28,6 @@ import {
updateWorkshop,
} from '../services/workshopService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -61,8 +60,7 @@ export function AddNewWorkshopPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
@@ -256,11 +254,13 @@ export function AddNewWorkshopPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('workshop.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('workshop.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -16,12 +16,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 formStyles from '../components/InvoiceForm.module.css'
@@ -41,6 +43,7 @@ export function InvoiceTemplateEditorPage() {
const [topText, setTopText] = useState('')
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -63,6 +66,7 @@ export function InvoiceTemplateEditorPage() {
setTopText(drafts.topText)
setItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts)
}
} catch (err) {
@@ -97,6 +101,7 @@ export function InvoiceTemplateEditorPage() {
topText: isEmptyRichText(topText) ? undefined : topText,
items: lineItems,
keyPoints: buildKeyPointsPayload(keyPoints),
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
accounts: buildAccountsPayload(accounts),
}
@@ -163,11 +168,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}
/>
+11
View File
@@ -535,6 +535,17 @@ export function InvoicesPage() {
</div>
) : null}
{(detailInvoice.paymentMethods?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.paymentMethods')}</h4>
<ul className={styles.keyPointList}>
{detailInvoice.paymentMethods!.map((pm) => (
<li key={pm.id}>{pm.text}</li>
))}
</ul>
</div>
) : null}
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
@@ -20,12 +20,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'
@@ -88,6 +90,7 @@ export function IssueInvoicePage() {
const [notes, setNotes] = useState('')
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
const [accounts, setAccounts] = useState<DraftAccount[]>([])
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -136,6 +139,7 @@ export function IssueInvoicePage() {
setNotes(drafts.notes)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
} else if (urlUserId && !presetCustomer) {
@@ -174,6 +178,7 @@ export function IssueInvoicePage() {
setNotes('')
setDraftItems([emptyDraftItem()])
setKeyPoints([])
setPaymentMethods([])
setAccounts([])
setSelectedItemTemplateId('')
setFormError('')
@@ -194,6 +199,7 @@ export function IssueInvoicePage() {
setTopText(drafts.topText)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setPaymentMethods(drafts.paymentMethods)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
setFormError('')
@@ -232,6 +238,7 @@ export function IssueInvoicePage() {
notes: notes.trim() || null,
invoiceTemplateId: invoiceTemplateId ?? null,
keyPoints: buildKeyPointsPayload(keyPoints),
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
accounts: buildAccountsPayload(accounts),
})
showToast(t('issueInvoice.updated'), 'success')
@@ -244,6 +251,7 @@ export function IssueInvoicePage() {
notes: notes.trim() || undefined,
invoiceTemplateId,
keyPoints: buildKeyPointsPayload(keyPoints),
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
accounts: buildAccountsPayload(accounts),
})
showToast(t('issueInvoice.issued'), 'success')
@@ -354,11 +362,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}
afterItems={
<div className={formStyles.totalRow}>
+15
View File
@@ -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
}
+27
View File
@@ -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) => ({
@@ -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<string>()
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 (
<div className={styles.treeGutters} aria-hidden>
{Array.from({ length: depth }, (_, level) => {
const isBranchLevel = level === depth - 1
return (
<span
key={level}
className={[
styles.treeGutter,
isBranchLevel ? styles.treeGutterBranch : styles.treeGutterContinue,
isBranchLevel && isLast ? styles.treeGutterBranchLast : '',
]
.filter(Boolean)
.join(' ')}
/>
)
})}
</div>
)
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<HTMLDivElement>(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 (
<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={selected ? 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"
/>
{allowEmpty && value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('myProducts.fields.clearCategory')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={dropdownClassName} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('myProducts.fields.noCategories')}</li>
) : (
filtered.map((option, index) => {
const isLastChild = isLastChildInGroup(filtered, index)
return (
<li
key={option.id}
className={[
styles.optionItem,
isLastChild ? styles.optionItemGroupEnd : '',
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
className={[
styles.option,
option.depth === 0 ? styles.optionParent : styles.optionChild,
value === option.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
{option.depth > 0 ? (
<CategoryTreeGutters depth={option.depth} isLast={isLastChild} />
) : null}
<span className={styles.optionBody}>
<span className={styles.optionLabel}>
{categoryLabel(option, isFa)}
</span>
{!isFa && option.nameFa ? (
<span className={styles.optionSecondary}>{option.nameFa}</span>
) : null}
{isFa && option.name && option.name !== option.nameFa ? (
<span className={styles.optionSecondary} dir="ltr">
{option.name}
</span>
) : null}
</span>
</button>
</li>
)
})
)}
</ul>
) : null}
</div>
<CategoryTreeSelect
id={id}
categories={categories}
value={value}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
allowEmpty={allowEmpty}
dropdownVariant={dropdownVariant}
/>
)
}
@@ -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);
}
@@ -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;
}
@@ -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<string> | null {
const q = query.trim()
if (!q) return null
const matched = categories.filter((category) => categoryMatches(category, q))
const visible = new Set<string>()
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<string>
visibleIds: Set<string> | 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 (
<div key={category.id}>
<div className={`${styles.row} ${selected ? styles.rowSelected : ''}`}>
{childCount ? (
<Tooltip
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
>
<button
type="button"
className={styles.toggleBtn}
aria-label={
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
}
aria-expanded={expanded}
onClick={() => onToggle(category.id)}
>
{expanded ? <Minus size={14} /> : <Plus size={14} />}
</button>
</Tooltip>
) : (
<span className={styles.toggleSpacer} />
)}
<button
type="button"
className={styles.selectBtn}
onClick={() => onSelect(category.id)}
>
<span
className={styles.name}
dir={isFa ? 'rtl' : 'ltr'}
lang={isFa ? 'fa' : 'en'}
>
{primary}
</span>
{secondary ? (
<span
className={styles.nameSecondary}
dir={isFa ? 'ltr' : 'rtl'}
lang={isFa ? 'en' : 'fa'}
>
· {secondary}
</span>
) : null}
</button>
</div>
{childCount ? (
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
<div className={treeStyles.childrenInner}>
<TreeNodes
categories={categories}
parentId={category.id}
value={value}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={forceExpanded}
onToggle={onToggle}
onSelect={onSelect}
/>
</div>
</div>
) : null}
</div>
)
})}
</>
)
}
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<Set<string>>(() => new Set())
const containerRef = useRef<HTMLDivElement>(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 (
<div
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
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 ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('categories.select.clear')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.fieldChevron} ${open ? styles.fieldChevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<div
className={[
styles.panel,
dropdownVariant === 'inFlow' ? styles.panelInFlow : styles.panelOverlay,
]
.filter(Boolean)
.join(' ')}
>
{allowEmpty ? (
<button
type="button"
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
onClick={() => selectOption('')}
>
{emptyOptionLabel}
</button>
) : null}
{!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p>
) : (
<TreeNodes
categories={categories}
parentId={null}
value={value}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={searching}
onToggle={handleToggle}
onSelect={selectOption}
/>
)}
</div>
) : null}
</div>
)
}
+14
View File
@@ -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<MessageKey, string> = {
'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': 'ارسال مجدد کد پیامکی',
@@ -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<HTMLInputElement>(`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({
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>Payment methods</h4>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
>
<Plus size={16} />
Add payment method
</button>
</div>
{paymentMethods.length === 0 ? (
<p className={styles.templateHint}>No payment methods yet.</p>
) : (
<div className={styles.repeatStack}>
{paymentMethods.map((method, index) => (
<div key={method.key} className={styles.repeatRow}>
<div className={`${tableStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
<input
data-payment-method-index={index}
value={method.text}
onChange={(e) =>
onPaymentMethodsChange(
paymentMethods.map((m) =>
m.key === method.key ? { ...m, text: e.target.value } : m,
),
)
}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
e.preventDefault()
const next = index + 1
if (next < paymentMethods.length) {
focusPaymentMethodInput(next)
return
}
onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])
window.setTimeout(() => focusPaymentMethodInput(next), 0)
}}
placeholder="e.g. Cash, card transfer, or installments"
aria-label={`Payment method ${index + 1}`}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() =>
onPaymentMethodsChange(paymentMethods.filter((m) => m.key !== method.key))
}
aria-label="Remove payment method"
title="Remove"
>
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>Account numbers</h4>
@@ -429,6 +429,17 @@ export function BusinessInvoicesPage() {
</div>
) : null}
{(detailInvoice.paymentMethods?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>Payment methods</h4>
<ul className={styles.keyPointList}>
{detailInvoice.paymentMethods!.map((pm) => (
<li key={pm.id}>{pm.text}</li>
))}
</ul>
</div>
) : null}
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
<div className={styles.blockSection}>
<h4 className={styles.blockTitle}>Accounts</h4>
@@ -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<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
const [accounts, setAccounts] = useState<DraftAccount[]>([])
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'}
</h2>
<p className={pageStyles.pageSubtitle}>
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.
</p>
</div>
@@ -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}
/>
@@ -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<DraftLineItem[]>([emptyDraftItem()])
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
const [accounts, setAccounts] = useState<DraftAccount[]>([])
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}
/>
@@ -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() {
</section>
) : null}
{(invoice.paymentMethods?.length ?? 0) > 0 ? (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t.paymentMethods}</h2>
<ul className={styles.keyPoints}>
{invoice.paymentMethods!.map((pm) => (
<li key={pm.id}>{pm.text}</li>
))}
</ul>
</section>
) : null}
{(invoice.accounts?.length ?? 0) > 0 ? (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t.accounts}</h2>
+16
View File
@@ -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
}
@@ -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) => ({
@@ -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 {