Unify category tree pickers and prefer free-text technical AI fields.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
838e84056e
commit
8465a29ce1
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Sparkles, X } from 'lucide-react'
|
import { Sparkles, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
@@ -8,8 +8,7 @@ import { ApiError } from '../lib/api'
|
|||||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||||
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
|
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
import { CategoryTreeSelect } from './CategoryTreeSelect'
|
||||||
import { SearchableSelect } from './SearchableSelect'
|
|
||||||
import modalStyles from './CategoryModal.module.css'
|
import modalStyles from './CategoryModal.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './AddProductByAiModal.module.css'
|
import styles from './AddProductByAiModal.module.css'
|
||||||
@@ -41,7 +40,6 @@ export function AddProductByAiModal({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => flattenCategories(categories), [categories])
|
|
||||||
const promptDir = language === 'fa' ? 'rtl' : 'ltr'
|
const promptDir = language === 'fa' ? 'rtl' : 'ltr'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -162,13 +160,13 @@ export function AddProductByAiModal({
|
|||||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
<div className={modalStyles.field}>
|
<div className={modalStyles.field}>
|
||||||
<label htmlFor="ai-product-category">{t('products.ai.category')}</label>
|
<label htmlFor="ai-product-category">{t('products.ai.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('products.ai.categoryPlaceholder')}
|
placeholder={t('products.ai.categoryPlaceholder')}
|
||||||
allowEmpty={false}
|
allowEmpty={false}
|
||||||
dropdownVariant="inFlow"
|
disabled={isSubmitting || isLoadingCategories}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ export function BrandModal({
|
|||||||
id="nameEn"
|
id="nameEn"
|
||||||
name="nameEn"
|
name="nameEn"
|
||||||
type="text"
|
type="text"
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
placeholder={t('brands.modal.nameEnPlaceholder')}
|
placeholder={t('brands.modal.nameEnPlaceholder')}
|
||||||
value={nameEn}
|
value={nameEn}
|
||||||
onChange={(e) => setNameEn(e.target.value)}
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field label {
|
.field label {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { X } from 'lucide-react'
|
|||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { Category, CategoryFormData } from '../types/category'
|
import type { Category, CategoryFormData } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
import { getDescendantIds } from '../utils/categories'
|
||||||
import { SearchableSelect } from './SearchableSelect'
|
import { CategoryTreeSelect } from './CategoryTreeSelect'
|
||||||
import { SeoMetaFields } from './SeoMetaFields'
|
import { SeoMetaFields } from './SeoMetaFields'
|
||||||
import styles from './CategoryModal.module.css'
|
import styles from './CategoryModal.module.css'
|
||||||
|
|
||||||
@@ -88,9 +88,12 @@ export function CategoryModal({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentOptions = flattenCategories(
|
const parentCategories = editingCategory
|
||||||
categories.filter((category) => category.id !== editingCategory?.id),
|
? categories.filter(
|
||||||
)
|
(category) =>
|
||||||
|
!getDescendantIds(categories, editingCategory.id).includes(category.id),
|
||||||
|
)
|
||||||
|
: categories
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -123,12 +126,13 @@ export function CategoryModal({
|
|||||||
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
|
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('categories.modal.parent')}</label>
|
<label>{t('categories.modal.parent')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={parentOptions}
|
categories={parentCategories}
|
||||||
value={parentId}
|
value={parentId}
|
||||||
onChange={setParentId}
|
onChange={setParentId}
|
||||||
placeholder={t('categories.select.search')}
|
placeholder={t('categories.select.search')}
|
||||||
dropdownVariant="inFlow"
|
allowEmpty
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,7 +158,8 @@ export function CategoryModal({
|
|||||||
id="nameEn"
|
id="nameEn"
|
||||||
name="nameEn"
|
name="nameEn"
|
||||||
type="text"
|
type="text"
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
placeholder={t('categories.modal.nameEnPlaceholder')}
|
placeholder={t('categories.modal.nameEnPlaceholder')}
|
||||||
value={nameEn}
|
value={nameEn}
|
||||||
onChange={(e) => setNameEn(e.target.value)}
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { ChevronDown, Search, X } from 'lucide-react'
|
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
|
||||||
import { useT } from '../i18n/useT'
|
|
||||||
import type { UserProductCategoryOption } from '../services/userProductsService'
|
import type { UserProductCategoryOption } from '../services/userProductsService'
|
||||||
import styles from './CategorySearchSelect.module.css'
|
import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
|
||||||
|
|
||||||
export interface FlatCategoryOption extends UserProductCategoryOption {
|
export interface FlatCategoryOption extends UserProductCategoryOption {
|
||||||
depth: number
|
depth: number
|
||||||
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
|
|||||||
dropdownVariant?: 'overlay' | 'inFlow'
|
dropdownVariant?: 'overlay' | 'inFlow'
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
|
/** @deprecated Prefer CategoryTreeSelect; kept for user-product category API shape. */
|
||||||
if (isFa) {
|
|
||||||
return category.nameFa?.trim() || category.name
|
|
||||||
}
|
|
||||||
return category.name || category.nameFa?.trim() || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function flattenCategoryTree(
|
export function flattenCategoryTree(
|
||||||
categories: UserProductCategoryOption[],
|
categories: UserProductCategoryOption[],
|
||||||
): FlatCategoryOption[] {
|
): FlatCategoryOption[] {
|
||||||
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const list of byParent.values()) {
|
for (const list of byParent.values()) {
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
|
||||||
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
|
|
||||||
return aLabel
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: FlatCategoryOption[] = []
|
const result: FlatCategoryOption[] = []
|
||||||
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
|
|||||||
function walk(parentId: string | null, depth: number) {
|
function walk(parentId: string | null, depth: number) {
|
||||||
const children = byParent.get(parentId) ?? []
|
const children = byParent.get(parentId) ?? []
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
|
if (!ids.has(child.id)) continue
|
||||||
result.push({ ...child, depth })
|
result.push({ ...child, depth })
|
||||||
walk(child.id, depth + 1)
|
walk(child.id, depth + 1)
|
||||||
}
|
}
|
||||||
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
|
|||||||
|
|
||||||
walk(null, 0)
|
walk(null, 0)
|
||||||
|
|
||||||
|
const rooted = new Set(result.map((item) => item.id))
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
if (category.parentId && !ids.has(category.parentId)) {
|
if (!rooted.has(category.id)) {
|
||||||
if (!result.some((item) => item.id === category.id)) {
|
result.push({ ...category, depth: 0 })
|
||||||
result.push({ ...category, depth: 0 })
|
|
||||||
walk(category.id, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterCategoryTree(
|
function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
|
||||||
flat: FlatCategoryOption[],
|
return options.map((option) => ({
|
||||||
query: string,
|
id: option.id,
|
||||||
): FlatCategoryOption[] {
|
parentId: option.parentId,
|
||||||
const q = query.trim().toLowerCase()
|
nameEn: option.name,
|
||||||
if (!q) return flat
|
nameFa: option.nameFa?.trim() || '',
|
||||||
|
}))
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-product marketplace categories — same tree UX as CategoryTreeSelect. */
|
||||||
export function CategorySearchSelect({
|
export function CategorySearchSelect({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
|
|||||||
allowEmpty = false,
|
allowEmpty = false,
|
||||||
dropdownVariant = 'overlay',
|
dropdownVariant = 'overlay',
|
||||||
}: CategorySearchSelectProps) {
|
}: CategorySearchSelectProps) {
|
||||||
const t = useT()
|
const categories = useMemo(() => toTreeItems(options), [options])
|
||||||
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(' ')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CategoryTreeSelect
|
||||||
className={styles.wrapper}
|
id={id}
|
||||||
ref={containerRef}
|
categories={categories}
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
value={value}
|
||||||
>
|
onChange={onChange}
|
||||||
<div
|
disabled={disabled}
|
||||||
className={[
|
placeholder={placeholder}
|
||||||
styles.inputWrap,
|
allowEmpty={allowEmpty}
|
||||||
open ? styles.inputWrapOpen : '',
|
dropdownVariant={dropdownVariant}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wrapperOpen {
|
||||||
|
z-index: 130;
|
||||||
|
}
|
||||||
|
|
||||||
.inputWrap {
|
.inputWrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -75,14 +79,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 120;
|
|
||||||
max-height: min(320px, 50vh);
|
max-height: min(320px, 50vh);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 0;
|
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: var(--elevated-surface);
|
background: var(--elevated-surface);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
@@ -90,6 +88,14 @@
|
|||||||
box-shadow: 0 10px 28px rgba(31, 38, 135, 0.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 {
|
.panelInFlow {
|
||||||
position: static;
|
position: static;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -98,6 +104,29 @@
|
|||||||
z-index: auto;
|
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 {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -135,7 +164,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: background 0.15s, color 0.15s, transform 0.2s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleBtn:hover {
|
.toggleBtn:hover {
|
||||||
@@ -149,23 +178,6 @@
|
|||||||
height: 24px;
|
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 {
|
.selectBtn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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 { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { Category } from '../types/category'
|
|
||||||
import { getAncestorIds, getChildren } from '../utils/categories'
|
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import treeStyles from './CategoryTree.module.css'
|
import treeStyles from './CategoryTree.module.css'
|
||||||
import styles from './CategoryTreeSelect.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 {
|
interface CategoryTreeSelectProps {
|
||||||
categories: Category[]
|
categories: CategoryTreeItem[]
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
id?: string
|
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'
|
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) {
|
if (isFa) {
|
||||||
return category.nameFa
|
return category.nameFa
|
||||||
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
|
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
|
||||||
@@ -30,7 +59,7 @@ function categoryLabel(category: Category, isFa: boolean) {
|
|||||||
: category.nameFa
|
: category.nameFa
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryMatches(category: Category, query: string) {
|
function categoryMatches(category: CategoryTreeItem, query: string) {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
if (!q) return true
|
if (!q) return true
|
||||||
return (
|
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()
|
const q = query.trim()
|
||||||
if (!q) return null
|
if (!q) return null
|
||||||
|
|
||||||
@@ -67,7 +99,7 @@ function TreeNodes({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
categories: Category[]
|
categories: CategoryTreeItem[]
|
||||||
parentId: string | null
|
parentId: string | null
|
||||||
value: string
|
value: string
|
||||||
expandedIds: Set<string>
|
expandedIds: Set<string>
|
||||||
@@ -114,9 +146,7 @@ function TreeNodes({
|
|||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
onClick={() => onToggle(category.id)}
|
onClick={() => onToggle(category.id)}
|
||||||
>
|
>
|
||||||
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
|
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
<ChevronRight size={16} />
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -127,9 +157,19 @@ function TreeNodes({
|
|||||||
className={styles.selectBtn}
|
className={styles.selectBtn}
|
||||||
onClick={() => onSelect(category.id)}
|
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 ? (
|
{secondary ? (
|
||||||
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
|
<span
|
||||||
|
className={styles.nameSecondary}
|
||||||
|
dir={isFa ? 'ltr' : 'rtl'}
|
||||||
|
lang={isFa ? 'en' : 'fa'}
|
||||||
|
>
|
||||||
· {secondary}
|
· {secondary}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -165,6 +205,8 @@ export function CategoryTreeSelect({
|
|||||||
placeholder,
|
placeholder,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
id,
|
id,
|
||||||
|
allowEmpty = false,
|
||||||
|
emptyLabel,
|
||||||
dropdownVariant = 'overlay',
|
dropdownVariant = 'overlay',
|
||||||
}: CategoryTreeSelectProps) {
|
}: CategoryTreeSelectProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
@@ -178,6 +220,7 @@ export function CategoryTreeSelect({
|
|||||||
const selected = categories.find((category) => category.id === value)
|
const selected = categories.find((category) => category.id === value)
|
||||||
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
||||||
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
||||||
|
const emptyOptionLabel = emptyLabel ?? t('categories.select.none')
|
||||||
|
|
||||||
const visibleIds = useMemo(
|
const visibleIds = useMemo(
|
||||||
() => visibleIdsForQuery(categories, query),
|
() => visibleIdsForQuery(categories, query),
|
||||||
@@ -213,17 +256,17 @@ export function CategoryTreeSelect({
|
|||||||
})
|
})
|
||||||
}, [categories, value])
|
}, [categories, value])
|
||||||
|
|
||||||
function handleToggle(id: string) {
|
function handleToggle(toggleId: string) {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(toggleId)) next.delete(toggleId)
|
||||||
else next.add(id)
|
else next.add(toggleId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOption(id: string) {
|
function selectOption(nextId: string) {
|
||||||
onChange(id)
|
onChange(nextId)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}
|
}
|
||||||
@@ -232,7 +275,11 @@ export function CategoryTreeSelect({
|
|||||||
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
||||||
|
|
||||||
return (
|
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 : ''}`}>
|
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
||||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
<Search size={16} className={styles.searchIcon} aria-hidden />
|
||||||
<input
|
<input
|
||||||
@@ -254,7 +301,7 @@ export function CategoryTreeSelect({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
/>
|
/>
|
||||||
{value && !open && !disabled ? (
|
{allowEmpty && value && !open && !disabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.clearBtn}
|
className={styles.clearBtn}
|
||||||
@@ -275,11 +322,20 @@ export function CategoryTreeSelect({
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
styles.panel,
|
styles.panel,
|
||||||
dropdownVariant === 'inFlow' ? styles.panelInFlow : '',
|
dropdownVariant === 'inFlow' ? styles.panelInFlow : styles.panelOverlay,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
>
|
>
|
||||||
|
{allowEmpty ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
|
||||||
|
onClick={() => selectOption('')}
|
||||||
|
>
|
||||||
|
{emptyOptionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{!hasVisible ? (
|
{!hasVisible ? (
|
||||||
<p className={styles.noResults}>{t('categories.select.empty')}</p>
|
<p className={styles.noResults}>{t('categories.select.empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartDay {
|
.chartDay {
|
||||||
gap: 1px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartMonth {
|
.chartMonth {
|
||||||
@@ -172,19 +172,26 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.barWrap {
|
.barWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 0;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chartMonth .barWrap {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.barWrap:hover,
|
.barWrap:hover,
|
||||||
.barWrap:focus-within {
|
.barWrap:focus-within {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -223,20 +230,19 @@
|
|||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
flex: none;
|
||||||
width: 100%;
|
width: 80%;
|
||||||
max-width: 8px;
|
max-width: 14px;
|
||||||
min-width: 2px;
|
min-width: 8px;
|
||||||
border-radius: 3px 3px 1px 1px;
|
border-radius: 3px 3px 1px 1px;
|
||||||
transition: height 0.3s ease;
|
transition: height 0.3s ease;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartMonth .bar {
|
.chartMonth .bar {
|
||||||
flex: none;
|
width: 12px;
|
||||||
width: 10px;
|
|
||||||
max-width: none;
|
max-width: none;
|
||||||
min-width: 10px;
|
min-width: 12px;
|
||||||
border-radius: 4px 4px 2px 2px;
|
border-radius: 4px 4px 2px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +252,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.barMonth {
|
.barMonth {
|
||||||
width: 10px;
|
width: 12px;
|
||||||
max-width: 10px;
|
max-width: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
|||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('website.linkGenerator.pickCategory')}
|
placeholder={t('website.linkGenerator.pickCategory')}
|
||||||
dropdownVariant="inFlow"
|
allowEmpty={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import type { ReactNode } from 'react'
|
|||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||||
import { formatIrtInput } from '../utils/irtPrice'
|
import { formatIrtInput } from '../utils/irtPrice'
|
||||||
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
|
import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
|
||||||
import {
|
import {
|
||||||
draftItemFromItemTemplate,
|
draftItemFromItemTemplate,
|
||||||
emptyDraftAccount,
|
emptyDraftAccount,
|
||||||
emptyDraftItem,
|
emptyDraftItem,
|
||||||
emptyDraftKeyPoint,
|
emptyDraftKeyPoint,
|
||||||
|
emptyDraftPaymentMethod,
|
||||||
} from '../utils/invoiceDraft'
|
} from '../utils/invoiceDraft'
|
||||||
import formStyles from './InvoiceForm.module.css'
|
import formStyles from './InvoiceForm.module.css'
|
||||||
import styles from './InvoiceDraftFields.module.css'
|
import styles from './InvoiceDraftFields.module.css'
|
||||||
@@ -19,15 +20,23 @@ function focusKeyPointInput(index: number) {
|
|||||||
el?.select()
|
el?.select()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusPaymentMethodInput(index: number) {
|
||||||
|
const el = document.querySelector<HTMLInputElement>(`input[data-payment-method-index="${index}"]`)
|
||||||
|
el?.focus()
|
||||||
|
el?.select()
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
itemTemplates: InvoiceItemTemplate[]
|
itemTemplates: InvoiceItemTemplate[]
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
keyPoints: DraftKeyPoint[]
|
keyPoints: DraftKeyPoint[]
|
||||||
|
paymentMethods: DraftPaymentMethod[]
|
||||||
accounts: DraftAccount[]
|
accounts: DraftAccount[]
|
||||||
selectedItemTemplateId: string
|
selectedItemTemplateId: string
|
||||||
onSelectedItemTemplateId: (id: string) => void
|
onSelectedItemTemplateId: (id: string) => void
|
||||||
onItemsChange: (items: DraftLineItem[]) => void
|
onItemsChange: (items: DraftLineItem[]) => void
|
||||||
onKeyPointsChange: (points: DraftKeyPoint[]) => void
|
onKeyPointsChange: (points: DraftKeyPoint[]) => void
|
||||||
|
onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
|
||||||
onAccountsChange: (accounts: DraftAccount[]) => void
|
onAccountsChange: (accounts: DraftAccount[]) => void
|
||||||
showItemTemplatePicker?: boolean
|
showItemTemplatePicker?: boolean
|
||||||
/** Rendered after line items (before key points / accounts), e.g. totals row. */
|
/** Rendered after line items (before key points / accounts), e.g. totals row. */
|
||||||
@@ -38,11 +47,13 @@ export function InvoiceDraftFields({
|
|||||||
itemTemplates,
|
itemTemplates,
|
||||||
items,
|
items,
|
||||||
keyPoints,
|
keyPoints,
|
||||||
|
paymentMethods,
|
||||||
accounts,
|
accounts,
|
||||||
selectedItemTemplateId,
|
selectedItemTemplateId,
|
||||||
onSelectedItemTemplateId,
|
onSelectedItemTemplateId,
|
||||||
onItemsChange,
|
onItemsChange,
|
||||||
onKeyPointsChange,
|
onKeyPointsChange,
|
||||||
|
onPaymentMethodsChange,
|
||||||
onAccountsChange,
|
onAccountsChange,
|
||||||
showItemTemplatePicker = true,
|
showItemTemplatePicker = true,
|
||||||
afterItems,
|
afterItems,
|
||||||
@@ -248,6 +259,67 @@ export function InvoiceDraftFields({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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.blockSection}>
|
||||||
<div className={styles.blockHeader}>
|
<div className={styles.blockHeader}>
|
||||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function PortfolioCard({
|
|||||||
</h3>
|
</h3>
|
||||||
) : null}
|
) : null}
|
||||||
{titleEn ? (
|
{titleEn ? (
|
||||||
<p className={cardStyles.titleEn} lang="en" dir={isFa ? 'rtl' : 'ltr'}>
|
<p className={cardStyles.titleEn} lang="en" dir="ltr">
|
||||||
{titleEn}
|
{titleEn}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -227,6 +227,8 @@
|
|||||||
.optionEn {
|
.optionEn {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionEn[dir='rtl'] {
|
.optionEn[dir='rtl'] {
|
||||||
|
|||||||
@@ -195,13 +195,17 @@ export function SearchableSelect({
|
|||||||
{cat.nameEn ? (
|
{cat.nameEn ? (
|
||||||
<>
|
<>
|
||||||
<span className={styles.optionSep}>·</span>
|
<span className={styles.optionSep}>·</span>
|
||||||
<span className={styles.optionEn}>{cat.nameEn}</span>
|
<span className={styles.optionEn} dir="ltr" lang="en">
|
||||||
|
{cat.nameEn}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className={styles.optionEn}>{cat.nameEn}</span>
|
<span className={styles.optionEn} dir="ltr" lang="en">
|
||||||
|
{cat.nameEn}
|
||||||
|
</span>
|
||||||
{cat.nameFa ? (
|
{cat.nameFa ? (
|
||||||
<>
|
<>
|
||||||
<span className={styles.optionSep}>·</span>
|
<span className={styles.optionSep}>·</span>
|
||||||
|
|||||||
@@ -10,18 +10,22 @@ export function buildTechnicalFormAiPrompt(categoryName: string) {
|
|||||||
return `برای دستهبندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
|
return `برای دستهبندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
|
||||||
|
|
||||||
برای هر فیلد این موارد را مشخص کن:
|
برای هر فیلد این موارد را مشخص کن:
|
||||||
- برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، رنگها)
|
- برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، ظرفیت)
|
||||||
- نوع ورودی: text، textarea، select، multi_select یا multi_value
|
- نوع ورودی: ترجیحاً text (یا textarea / multi_value در صورت نیاز)
|
||||||
- الزامی بودن یا نبودن
|
- الزامی بودن یا نبودن
|
||||||
- برای select / multi_select: فهرست گزینهها به فارسی
|
|
||||||
- multi_value یعنی لیست تکراری از مقادیر متنی آزاد (بدون گزینه)، مناسب برای ویژگیهای چندتایی
|
- multi_value یعنی لیست تکراری از مقادیر متنی آزاد (بدون گزینه)، مناسب برای ویژگیهای چندتایی
|
||||||
|
|
||||||
|
مهم — نوع ورودی:
|
||||||
|
- ترجیحاً از ورودی text استفاده کن تا فروشنده بتواند مقدار دلخواه را آزادانه وارد کند.
|
||||||
|
- از select و multi_select حتیالمقدور استفاده نکن؛ فقط وقتی گزینهها واقعاً ثابت و استانداردند (مثل بله/خیر).
|
||||||
|
- اگر بین text و select مردد هستی، text را انتخاب کن.
|
||||||
|
|
||||||
همیشه در انتهای فرم یک فیلد «سایر ویژگیها» از نوع multi_value در نظر گرفته میشود؛ خودت آن را نساز.
|
همیشه در انتهای فرم یک فیلد «سایر ویژگیها» از نوع multi_value در نظر گرفته میشود؛ خودت آن را نساز.
|
||||||
|
|
||||||
اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را هم بهصورت text اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتیمتر یا کیلوگرم). اگر برای دسته بیمعناست، اضافه نکن.
|
اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را هم بهصورت text اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتیمتر یا کیلوگرم). اگر برای دسته بیمعناست، اضافه نکن.
|
||||||
|
|
||||||
روی مشخصات و ویژگیهایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی میکنند.
|
روی مشخصات و ویژگیهایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی میکنند.
|
||||||
برچسبها و گزینهها را به فارسی بنویس.
|
برچسبها را به فارسی بنویس.
|
||||||
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز میکند و فرم را بر اساس مشخصات واقعی آن میسازد).`
|
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز میکند و فرم را بر اساس مشخصات واقعی آن میسازد).`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -739,6 +739,7 @@ const en = {
|
|||||||
'seo.meta.descriptionPlaceholder': 'Meta description for search results',
|
'seo.meta.descriptionPlaceholder': 'Meta description for search results',
|
||||||
'categories.select.search': 'Search categories...',
|
'categories.select.search': 'Search categories...',
|
||||||
'categories.select.none': '— No parent (top-level category) —',
|
'categories.select.none': '— No parent (top-level category) —',
|
||||||
|
'categories.select.noneCategory': '— No category —',
|
||||||
'categories.select.empty': 'No categories found',
|
'categories.select.empty': 'No categories found',
|
||||||
'categories.select.clear': 'Clear selection',
|
'categories.select.clear': 'Clear selection',
|
||||||
|
|
||||||
@@ -2424,6 +2425,12 @@ const en = {
|
|||||||
'invoiceDraft.keyPointPlaceholder': 'e.g. Payment due within 7 days',
|
'invoiceDraft.keyPointPlaceholder': 'e.g. Payment due within 7 days',
|
||||||
'invoiceDraft.keyPointAria': 'Key point {index}',
|
'invoiceDraft.keyPointAria': 'Key point {index}',
|
||||||
'invoiceDraft.removeKeyPoint': 'Remove key point',
|
'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.accounts': 'Account numbers',
|
||||||
'invoiceDraft.addAccount': 'Add account',
|
'invoiceDraft.addAccount': 'Add account',
|
||||||
'invoiceDraft.noAccounts': 'No bank accounts yet.',
|
'invoiceDraft.noAccounts': 'No bank accounts yet.',
|
||||||
@@ -2491,7 +2498,7 @@ const en = {
|
|||||||
'invoiceTemplateEditor.addTitle': 'Add invoice template',
|
'invoiceTemplateEditor.addTitle': 'Add invoice template',
|
||||||
'invoiceTemplateEditor.editTitle': 'Edit invoice template',
|
'invoiceTemplateEditor.editTitle': 'Edit invoice template',
|
||||||
'invoiceTemplateEditor.subtitle':
|
'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.name': 'Name',
|
||||||
'invoiceTemplateEditor.namePlaceholder': 'e.g. Standard website package',
|
'invoiceTemplateEditor.namePlaceholder': 'e.g. Standard website package',
|
||||||
'invoiceTemplateEditor.topText': 'Top text',
|
'invoiceTemplateEditor.topText': 'Top text',
|
||||||
@@ -3356,6 +3363,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'seo.meta.descriptionPlaceholder': 'توضیحات متا برای نتایج جستجو',
|
'seo.meta.descriptionPlaceholder': 'توضیحات متا برای نتایج جستجو',
|
||||||
'categories.select.search': 'جستجوی دستهبندیها...',
|
'categories.select.search': 'جستجوی دستهبندیها...',
|
||||||
'categories.select.none': '— بدون دسته والد (دسته سطح بالا) —',
|
'categories.select.none': '— بدون دسته والد (دسته سطح بالا) —',
|
||||||
|
'categories.select.noneCategory': '— بدون دستهبندی —',
|
||||||
'categories.select.empty': 'دستهبندیای یافت نشد',
|
'categories.select.empty': 'دستهبندیای یافت نشد',
|
||||||
'categories.select.clear': 'پاک کردن انتخاب',
|
'categories.select.clear': 'پاک کردن انتخاب',
|
||||||
|
|
||||||
@@ -5043,6 +5051,12 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'invoiceDraft.keyPointPlaceholder': 'مثلاً پرداخت ظرف ۷ روز',
|
'invoiceDraft.keyPointPlaceholder': 'مثلاً پرداخت ظرف ۷ روز',
|
||||||
'invoiceDraft.keyPointAria': 'نکته کلیدی {index}',
|
'invoiceDraft.keyPointAria': 'نکته کلیدی {index}',
|
||||||
'invoiceDraft.removeKeyPoint': 'حذف نکته کلیدی',
|
'invoiceDraft.removeKeyPoint': 'حذف نکته کلیدی',
|
||||||
|
'invoiceDraft.paymentMethods': 'نحوه ی پرداخت',
|
||||||
|
'invoiceDraft.addPaymentMethod': 'افزودن نحوه پرداخت',
|
||||||
|
'invoiceDraft.noPaymentMethods': 'هنوز نحوه پرداختی ثبت نشده است.',
|
||||||
|
'invoiceDraft.paymentMethodPlaceholder': 'مثلاً نقد، کارت به کارت یا اقساط',
|
||||||
|
'invoiceDraft.paymentMethodAria': 'نحوه پرداخت {index}',
|
||||||
|
'invoiceDraft.removePaymentMethod': 'حذف نحوه پرداخت',
|
||||||
'invoiceDraft.accounts': 'شماره حسابها',
|
'invoiceDraft.accounts': 'شماره حسابها',
|
||||||
'invoiceDraft.addAccount': 'افزودن حساب',
|
'invoiceDraft.addAccount': 'افزودن حساب',
|
||||||
'invoiceDraft.noAccounts': 'هنوز حساب بانکیای ثبت نشده است.',
|
'invoiceDraft.noAccounts': 'هنوز حساب بانکیای ثبت نشده است.',
|
||||||
@@ -5110,7 +5124,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور',
|
'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور',
|
||||||
'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور',
|
'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور',
|
||||||
'invoiceTemplateEditor.subtitle':
|
'invoiceTemplateEditor.subtitle':
|
||||||
'نام، متن بالا، آیتمها، نکات کلیدی و حسابهای بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.',
|
'نام، متن بالا، آیتمها، نکات کلیدی، نحوه ی پرداخت و حسابهای بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.',
|
||||||
'invoiceTemplateEditor.name': 'نام',
|
'invoiceTemplateEditor.name': 'نام',
|
||||||
'invoiceTemplateEditor.namePlaceholder': 'مثلاً بسته استاندارد سایت',
|
'invoiceTemplateEditor.namePlaceholder': 'مثلاً بسته استاندارد سایت',
|
||||||
'invoiceTemplateEditor.topText': 'متن بالای فاکتور',
|
'invoiceTemplateEditor.topText': 'متن بالای فاکتور',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import type { BlogPostType } from '../types/blog'
|
import type { BlogPostType } from '../types/blog'
|
||||||
import { BLOG_TYPE_OPTIONS } from '../types/blog'
|
import { BLOG_TYPE_OPTIONS } from '../types/blog'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -61,8 +60,7 @@ export function AddNewBlogPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const selectedAspect =
|
||||||
const selectedAspect =
|
|
||||||
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -273,11 +271,13 @@ export function AddNewBlogPage() {
|
|||||||
|
|
||||||
<div className={`${styles.field} ${styles.col6Span}`}>
|
<div className={`${styles.field} ${styles.col6Span}`}>
|
||||||
<label>{t('blog.form.category')}</label>
|
<label>{t('blog.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('blog.form.categoryPlaceholder')}
|
placeholder={t('blog.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import type { VideoProvider } from '../types/instruction'
|
import type { VideoProvider } from '../types/instruction'
|
||||||
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
|
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -63,8 +62,7 @@ export function AddNewInstructionPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const selectedAspect =
|
||||||
const selectedAspect =
|
|
||||||
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
|
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -257,11 +255,13 @@ export function AddNewInstructionPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('instruction.form.category')}</label>
|
<label>{t('instruction.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('instruction.form.categoryPlaceholder')}
|
placeholder={t('instruction.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { ImageUploader } from '../components/ImageUploader'
|
import { ImageUploader } from '../components/ImageUploader'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
updatePortfolio,
|
updatePortfolio,
|
||||||
} from '../services/portfolioService'
|
} from '../services/portfolioService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -70,8 +69,7 @@ export function AddNewPortfolioPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const selectedAspect =
|
||||||
const selectedAspect =
|
|
||||||
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,11 +275,13 @@ export function AddNewPortfolioPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('portfolio.form.category')}</label>
|
<label>{t('portfolio.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('portfolio.form.categoryPlaceholder')}
|
placeholder={t('portfolio.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { SearchableSelect } from '../components/SearchableSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { ImageUploader } from '../components/ImageUploader'
|
import { ImageUploader } from '../components/ImageUploader'
|
||||||
@@ -28,7 +29,6 @@ import {
|
|||||||
fillProductFieldByAi,
|
fillProductFieldByAi,
|
||||||
} from '../services/productAiService'
|
} from '../services/productAiService'
|
||||||
import type { Category, FlatCategory } from '../types/category'
|
import type { Category, FlatCategory } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -60,12 +60,11 @@ export function AddNewProductPage() {
|
|||||||
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
|
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
|
||||||
const [isAiRunning, setIsAiRunning] = useState(false)
|
const [isAiRunning, setIsAiRunning] = useState(false)
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
|
||||||
const selectedCategoryName = useMemo(() => {
|
const selectedCategoryName = useMemo(() => {
|
||||||
const match = categoryOptions.find((item) => item.id === categoryId)
|
const match = categories.find((item) => item.id === categoryId)
|
||||||
if (!match) return ''
|
if (!match) return ''
|
||||||
return match.nameFa?.trim() || match.nameEn || ''
|
return match.nameFa?.trim() || match.nameEn || ''
|
||||||
}, [categoryOptions, categoryId])
|
}, [categories, categoryId])
|
||||||
|
|
||||||
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
|
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
|
||||||
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
|
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
|
||||||
@@ -293,8 +292,8 @@ export function AddNewProductPage() {
|
|||||||
<div className={styles.topFields}>
|
<div className={styles.topFields}>
|
||||||
<div className={`${styles.field} ${styles.fieldCategory}`}>
|
<div className={`${styles.field} ${styles.fieldCategory}`}>
|
||||||
<label>{t('products.form.category')}</label>
|
<label>{t('products.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('products.form.categoryPlaceholder')}
|
placeholder={t('products.form.categoryPlaceholder')}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import type { VideoProvider } from '../types/video'
|
import type { VideoProvider } from '../types/video'
|
||||||
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
|
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -55,7 +54,6 @@ export function AddNewVideoPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -218,11 +216,13 @@ export function AddNewVideoPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('video.form.category')}</label>
|
<label>{t('video.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('video.form.categoryPlaceholder')}
|
placeholder={t('video.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { ImageUploader } from '../components/ImageUploader'
|
import { ImageUploader } from '../components/ImageUploader'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
updateWorkshop,
|
updateWorkshop,
|
||||||
} from '../services/workshopService'
|
} from '../services/workshopService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -61,7 +60,6 @@ export function AddNewWorkshopPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -256,11 +254,13 @@ export function AddNewWorkshopPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('workshop.form.category')}</label>
|
<label>{t('workshop.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('workshop.form.categoryPlaceholder')}
|
placeholder={t('workshop.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import type { InvoiceItemTemplate } from '../types/invoice'
|
|||||||
import {
|
import {
|
||||||
buildAccountsPayload,
|
buildAccountsPayload,
|
||||||
buildKeyPointsPayload,
|
buildKeyPointsPayload,
|
||||||
|
buildPaymentMethodsPayload,
|
||||||
buildTemplateItemsPayload,
|
buildTemplateItemsPayload,
|
||||||
draftsFromInvoiceTemplate,
|
draftsFromInvoiceTemplate,
|
||||||
emptyDraftItem,
|
emptyDraftItem,
|
||||||
type DraftAccount,
|
type DraftAccount,
|
||||||
type DraftKeyPoint,
|
type DraftKeyPoint,
|
||||||
type DraftLineItem,
|
type DraftLineItem,
|
||||||
|
type DraftPaymentMethod,
|
||||||
} from '../utils/invoiceDraft'
|
} from '../utils/invoiceDraft'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import formStyles from '../components/InvoiceForm.module.css'
|
import formStyles from '../components/InvoiceForm.module.css'
|
||||||
@@ -41,6 +43,7 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
const [topText, setTopText] = useState('')
|
const [topText, setTopText] = useState('')
|
||||||
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
|
||||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -63,6 +66,7 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
setTopText(drafts.topText)
|
setTopText(drafts.topText)
|
||||||
setItems(drafts.items)
|
setItems(drafts.items)
|
||||||
setKeyPoints(drafts.keyPoints)
|
setKeyPoints(drafts.keyPoints)
|
||||||
|
setPaymentMethods(drafts.paymentMethods)
|
||||||
setAccounts(drafts.accounts)
|
setAccounts(drafts.accounts)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -97,6 +101,7 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
topText: isEmptyRichText(topText) ? undefined : topText,
|
topText: isEmptyRichText(topText) ? undefined : topText,
|
||||||
items: lineItems,
|
items: lineItems,
|
||||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||||
|
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
|
||||||
accounts: buildAccountsPayload(accounts),
|
accounts: buildAccountsPayload(accounts),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +168,13 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
itemTemplates={itemTemplates}
|
itemTemplates={itemTemplates}
|
||||||
items={items}
|
items={items}
|
||||||
keyPoints={keyPoints}
|
keyPoints={keyPoints}
|
||||||
|
paymentMethods={paymentMethods}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
selectedItemTemplateId={selectedItemTemplateId}
|
selectedItemTemplateId={selectedItemTemplateId}
|
||||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||||
onItemsChange={setItems}
|
onItemsChange={setItems}
|
||||||
onKeyPointsChange={setKeyPoints}
|
onKeyPointsChange={setKeyPoints}
|
||||||
|
onPaymentMethodsChange={setPaymentMethods}
|
||||||
onAccountsChange={setAccounts}
|
onAccountsChange={setAccounts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -535,6 +535,17 @@ export function InvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
|
||||||
<div className={styles.blockSection}>
|
<div className={styles.blockSection}>
|
||||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import {
|
|||||||
buildAccountsPayload,
|
buildAccountsPayload,
|
||||||
buildKeyPointsPayload,
|
buildKeyPointsPayload,
|
||||||
buildLineItemsPayload,
|
buildLineItemsPayload,
|
||||||
|
buildPaymentMethodsPayload,
|
||||||
draftsFromInvoice,
|
draftsFromInvoice,
|
||||||
draftsFromInvoiceTemplate,
|
draftsFromInvoiceTemplate,
|
||||||
emptyDraftItem,
|
emptyDraftItem,
|
||||||
type DraftAccount,
|
type DraftAccount,
|
||||||
type DraftKeyPoint,
|
type DraftKeyPoint,
|
||||||
type DraftLineItem,
|
type DraftLineItem,
|
||||||
|
type DraftPaymentMethod,
|
||||||
} from '../utils/invoiceDraft'
|
} from '../utils/invoiceDraft'
|
||||||
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
@@ -88,6 +90,7 @@ export function IssueInvoicePage() {
|
|||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
|
||||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -136,6 +139,7 @@ export function IssueInvoicePage() {
|
|||||||
setNotes(drafts.notes)
|
setNotes(drafts.notes)
|
||||||
setDraftItems(drafts.items)
|
setDraftItems(drafts.items)
|
||||||
setKeyPoints(drafts.keyPoints)
|
setKeyPoints(drafts.keyPoints)
|
||||||
|
setPaymentMethods(drafts.paymentMethods)
|
||||||
setAccounts(drafts.accounts)
|
setAccounts(drafts.accounts)
|
||||||
setSelectedItemTemplateId('')
|
setSelectedItemTemplateId('')
|
||||||
} else if (urlUserId && !presetCustomer) {
|
} else if (urlUserId && !presetCustomer) {
|
||||||
@@ -174,6 +178,7 @@ export function IssueInvoicePage() {
|
|||||||
setNotes('')
|
setNotes('')
|
||||||
setDraftItems([emptyDraftItem()])
|
setDraftItems([emptyDraftItem()])
|
||||||
setKeyPoints([])
|
setKeyPoints([])
|
||||||
|
setPaymentMethods([])
|
||||||
setAccounts([])
|
setAccounts([])
|
||||||
setSelectedItemTemplateId('')
|
setSelectedItemTemplateId('')
|
||||||
setFormError('')
|
setFormError('')
|
||||||
@@ -194,6 +199,7 @@ export function IssueInvoicePage() {
|
|||||||
setTopText(drafts.topText)
|
setTopText(drafts.topText)
|
||||||
setDraftItems(drafts.items)
|
setDraftItems(drafts.items)
|
||||||
setKeyPoints(drafts.keyPoints)
|
setKeyPoints(drafts.keyPoints)
|
||||||
|
setPaymentMethods(drafts.paymentMethods)
|
||||||
setAccounts(drafts.accounts)
|
setAccounts(drafts.accounts)
|
||||||
setSelectedItemTemplateId('')
|
setSelectedItemTemplateId('')
|
||||||
setFormError('')
|
setFormError('')
|
||||||
@@ -232,6 +238,7 @@ export function IssueInvoicePage() {
|
|||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
invoiceTemplateId: invoiceTemplateId ?? null,
|
invoiceTemplateId: invoiceTemplateId ?? null,
|
||||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||||
|
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
|
||||||
accounts: buildAccountsPayload(accounts),
|
accounts: buildAccountsPayload(accounts),
|
||||||
})
|
})
|
||||||
showToast(t('issueInvoice.updated'), 'success')
|
showToast(t('issueInvoice.updated'), 'success')
|
||||||
@@ -244,6 +251,7 @@ export function IssueInvoicePage() {
|
|||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
invoiceTemplateId,
|
invoiceTemplateId,
|
||||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||||
|
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
|
||||||
accounts: buildAccountsPayload(accounts),
|
accounts: buildAccountsPayload(accounts),
|
||||||
})
|
})
|
||||||
showToast(t('issueInvoice.issued'), 'success')
|
showToast(t('issueInvoice.issued'), 'success')
|
||||||
@@ -354,11 +362,13 @@ export function IssueInvoicePage() {
|
|||||||
itemTemplates={itemTemplates}
|
itemTemplates={itemTemplates}
|
||||||
items={draftItems}
|
items={draftItems}
|
||||||
keyPoints={keyPoints}
|
keyPoints={keyPoints}
|
||||||
|
paymentMethods={paymentMethods}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
selectedItemTemplateId={selectedItemTemplateId}
|
selectedItemTemplateId={selectedItemTemplateId}
|
||||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||||
onItemsChange={setDraftItems}
|
onItemsChange={setDraftItems}
|
||||||
onKeyPointsChange={setKeyPoints}
|
onKeyPointsChange={setKeyPoints}
|
||||||
|
onPaymentMethodsChange={setPaymentMethods}
|
||||||
onAccountsChange={setAccounts}
|
onAccountsChange={setAccounts}
|
||||||
afterItems={
|
afterItems={
|
||||||
<div className={formStyles.totalRow}>
|
<div className={formStyles.totalRow}>
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export interface InvoiceKeyPoint {
|
|||||||
sortOrder: number
|
sortOrder: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvoicePaymentMethod {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface InvoiceAccount {
|
export interface InvoiceAccount {
|
||||||
id: string
|
id: string
|
||||||
bankName: string
|
bankName: string
|
||||||
@@ -79,6 +85,7 @@ export interface Invoice {
|
|||||||
} | null
|
} | null
|
||||||
items?: InvoiceItem[]
|
items?: InvoiceItem[]
|
||||||
keyPoints?: InvoiceKeyPoint[]
|
keyPoints?: InvoiceKeyPoint[]
|
||||||
|
paymentMethods?: InvoicePaymentMethod[]
|
||||||
accounts?: InvoiceAccount[]
|
accounts?: InvoiceAccount[]
|
||||||
subtotal?: number
|
subtotal?: number
|
||||||
total?: number
|
total?: number
|
||||||
@@ -98,6 +105,10 @@ export interface InvoiceKeyPointInput {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvoicePaymentMethodInput {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface InvoiceAccountInput {
|
export interface InvoiceAccountInput {
|
||||||
bankName: string
|
bankName: string
|
||||||
accountHolderName?: string
|
accountHolderName?: string
|
||||||
@@ -114,6 +125,7 @@ export interface CreateInvoicePayload {
|
|||||||
notes?: string
|
notes?: string
|
||||||
invoiceTemplateId?: string
|
invoiceTemplateId?: string
|
||||||
keyPoints?: InvoiceKeyPointInput[]
|
keyPoints?: InvoiceKeyPointInput[]
|
||||||
|
paymentMethods?: InvoicePaymentMethodInput[]
|
||||||
accounts?: InvoiceAccountInput[]
|
accounts?: InvoiceAccountInput[]
|
||||||
status?: InvoiceStatus
|
status?: InvoiceStatus
|
||||||
}
|
}
|
||||||
@@ -126,6 +138,7 @@ export type UpdateInvoicePayload = {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
invoiceTemplateId?: string | null
|
invoiceTemplateId?: string | null
|
||||||
keyPoints?: InvoiceKeyPointInput[]
|
keyPoints?: InvoiceKeyPointInput[]
|
||||||
|
paymentMethods?: InvoicePaymentMethodInput[]
|
||||||
accounts?: InvoiceAccountInput[]
|
accounts?: InvoiceAccountInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +166,7 @@ export interface InvoiceTemplate {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
items: InvoiceTemplateItem[]
|
items: InvoiceTemplateItem[]
|
||||||
keyPoints: InvoiceKeyPoint[]
|
keyPoints: InvoiceKeyPoint[]
|
||||||
|
paymentMethods: InvoicePaymentMethod[]
|
||||||
accounts: InvoiceAccount[]
|
accounts: InvoiceAccount[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +185,7 @@ export interface CreateInvoiceTemplatePayload {
|
|||||||
topText?: string
|
topText?: string
|
||||||
items: InvoiceTemplateItemInput[]
|
items: InvoiceTemplateItemInput[]
|
||||||
keyPoints?: InvoiceKeyPointInput[]
|
keyPoints?: InvoiceKeyPointInput[]
|
||||||
|
paymentMethods?: InvoicePaymentMethodInput[]
|
||||||
accounts?: InvoiceAccountInput[]
|
accounts?: InvoiceAccountInput[]
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
InvoiceItemInput,
|
InvoiceItemInput,
|
||||||
InvoiceItemTemplate,
|
InvoiceItemTemplate,
|
||||||
InvoiceKeyPointInput,
|
InvoiceKeyPointInput,
|
||||||
|
InvoicePaymentMethodInput,
|
||||||
InvoiceTemplate,
|
InvoiceTemplate,
|
||||||
InvoiceTemplateItemInput,
|
InvoiceTemplateItemInput,
|
||||||
} from '../types/invoice'
|
} from '../types/invoice'
|
||||||
@@ -40,6 +41,8 @@ export type DraftLineItem = {
|
|||||||
|
|
||||||
export type DraftKeyPoint = { key: string; text: string }
|
export type DraftKeyPoint = { key: string; text: string }
|
||||||
|
|
||||||
|
export type DraftPaymentMethod = { key: string; text: string }
|
||||||
|
|
||||||
export type DraftAccount = {
|
export type DraftAccount = {
|
||||||
key: string
|
key: string
|
||||||
bankName: string
|
bankName: string
|
||||||
@@ -68,6 +71,10 @@ export function emptyDraftKeyPoint(): DraftKeyPoint {
|
|||||||
return { key: newKey(), text: '' }
|
return { key: newKey(), text: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function emptyDraftPaymentMethod(): DraftPaymentMethod {
|
||||||
|
return { key: newKey(), text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyDraftAccount(): DraftAccount {
|
export function emptyDraftAccount(): DraftAccount {
|
||||||
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
|
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
|
||||||
}
|
}
|
||||||
@@ -93,6 +100,7 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
|
|||||||
topText: string
|
topText: string
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
keyPoints: DraftKeyPoint[]
|
keyPoints: DraftKeyPoint[]
|
||||||
|
paymentMethods: DraftPaymentMethod[]
|
||||||
accounts: DraftAccount[]
|
accounts: DraftAccount[]
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
@@ -118,6 +126,10 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
|
|||||||
template.keyPoints.length > 0
|
template.keyPoints.length > 0
|
||||||
? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text }))
|
? 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:
|
accounts:
|
||||||
template.accounts.length > 0
|
template.accounts.length > 0
|
||||||
? template.accounts.map((acc) => ({
|
? template.accounts.map((acc) => ({
|
||||||
@@ -145,6 +157,7 @@ export function draftsFromInvoice(invoice: {
|
|||||||
discountedPrice: number | null
|
discountedPrice: number | null
|
||||||
}>
|
}>
|
||||||
keyPoints?: Array<{ text: string }>
|
keyPoints?: Array<{ text: string }>
|
||||||
|
paymentMethods?: Array<{ text: string }>
|
||||||
accounts?: Array<{
|
accounts?: Array<{
|
||||||
bankName: string
|
bankName: string
|
||||||
accountHolderName: string | null
|
accountHolderName: string | null
|
||||||
@@ -157,6 +170,7 @@ export function draftsFromInvoice(invoice: {
|
|||||||
notes: string
|
notes: string
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
keyPoints: DraftKeyPoint[]
|
keyPoints: DraftKeyPoint[]
|
||||||
|
paymentMethods: DraftPaymentMethod[]
|
||||||
accounts: DraftAccount[]
|
accounts: DraftAccount[]
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
@@ -183,6 +197,10 @@ export function draftsFromInvoice(invoice: {
|
|||||||
(invoice.keyPoints?.length ?? 0) > 0
|
(invoice.keyPoints?.length ?? 0) > 0
|
||||||
? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text }))
|
? 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:
|
accounts:
|
||||||
(invoice.accounts?.length ?? 0) > 0
|
(invoice.accounts?.length ?? 0) > 0
|
||||||
? invoice.accounts!.map((acc) => ({
|
? invoice.accounts!.map((acc) => ({
|
||||||
@@ -248,6 +266,15 @@ export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointI
|
|||||||
.map((text) => ({ text }))
|
.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[] {
|
export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] {
|
||||||
return accounts
|
return accounts
|
||||||
.map((acc) => ({
|
.map((acc) => ({
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { ChevronDown, Search, X } from 'lucide-react'
|
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
|
||||||
import { useT } from '../i18n/useT'
|
|
||||||
import type { UserProductCategoryOption } from '../services/userProductsService'
|
import type { UserProductCategoryOption } from '../services/userProductsService'
|
||||||
import styles from './CategorySearchSelect.module.css'
|
import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
|
||||||
|
|
||||||
export interface FlatCategoryOption extends UserProductCategoryOption {
|
export interface FlatCategoryOption extends UserProductCategoryOption {
|
||||||
depth: number
|
depth: number
|
||||||
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
|
|||||||
dropdownVariant?: 'overlay' | 'inFlow'
|
dropdownVariant?: 'overlay' | 'inFlow'
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
|
/** @deprecated Prefer CategoryTreeSelect; kept for callers that flatten options. */
|
||||||
if (isFa) {
|
|
||||||
return category.nameFa?.trim() || category.name
|
|
||||||
}
|
|
||||||
return category.name || category.nameFa?.trim() || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function flattenCategoryTree(
|
export function flattenCategoryTree(
|
||||||
categories: UserProductCategoryOption[],
|
categories: UserProductCategoryOption[],
|
||||||
): FlatCategoryOption[] {
|
): FlatCategoryOption[] {
|
||||||
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const list of byParent.values()) {
|
for (const list of byParent.values()) {
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
|
||||||
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
|
|
||||||
return aLabel
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: FlatCategoryOption[] = []
|
const result: FlatCategoryOption[] = []
|
||||||
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
|
|||||||
function walk(parentId: string | null, depth: number) {
|
function walk(parentId: string | null, depth: number) {
|
||||||
const children = byParent.get(parentId) ?? []
|
const children = byParent.get(parentId) ?? []
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
|
if (!ids.has(child.id)) continue
|
||||||
result.push({ ...child, depth })
|
result.push({ ...child, depth })
|
||||||
walk(child.id, depth + 1)
|
walk(child.id, depth + 1)
|
||||||
}
|
}
|
||||||
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
|
|||||||
|
|
||||||
walk(null, 0)
|
walk(null, 0)
|
||||||
|
|
||||||
|
const rooted = new Set(result.map((item) => item.id))
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
if (category.parentId && !ids.has(category.parentId)) {
|
if (!rooted.has(category.id)) {
|
||||||
if (!result.some((item) => item.id === category.id)) {
|
result.push({ ...category, depth: 0 })
|
||||||
result.push({ ...category, depth: 0 })
|
|
||||||
walk(category.id, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterCategoryTree(
|
function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
|
||||||
flat: FlatCategoryOption[],
|
return options.map((option) => ({
|
||||||
query: string,
|
id: option.id,
|
||||||
): FlatCategoryOption[] {
|
parentId: option.parentId,
|
||||||
const q = query.trim().toLowerCase()
|
nameEn: option.name,
|
||||||
if (!q) return flat
|
nameFa: option.nameFa?.trim() || '',
|
||||||
|
}))
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marketplace categories — same collapsible tree UX as business dashboards. */
|
||||||
export function CategorySearchSelect({
|
export function CategorySearchSelect({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
|
|||||||
allowEmpty = false,
|
allowEmpty = false,
|
||||||
dropdownVariant = 'overlay',
|
dropdownVariant = 'overlay',
|
||||||
}: CategorySearchSelectProps) {
|
}: CategorySearchSelectProps) {
|
||||||
const t = useT()
|
const categories = useMemo(() => toTreeItems(options), [options])
|
||||||
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(' ')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CategoryTreeSelect
|
||||||
className={styles.wrapper}
|
id={id}
|
||||||
ref={containerRef}
|
categories={categories}
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
value={value}
|
||||||
>
|
onChange={onChange}
|
||||||
<div
|
disabled={disabled}
|
||||||
className={[
|
placeholder={placeholder}
|
||||||
styles.inputWrap,
|
allowEmpty={allowEmpty}
|
||||||
open ? styles.inputWrapOpen : '',
|
dropdownVariant={dropdownVariant}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -396,6 +396,13 @@ const en = {
|
|||||||
'otp.signingIn': 'Signing in...',
|
'otp.signingIn': 'Signing in...',
|
||||||
'otp.error.signIn': 'Unable to sign in with SMS code.',
|
'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.close': 'Close',
|
||||||
'common.resendIn': 'Resend code in {seconds}s',
|
'common.resendIn': 'Resend code in {seconds}s',
|
||||||
'common.resend': 'Resend SMS code',
|
'common.resend': 'Resend SMS code',
|
||||||
@@ -800,6 +807,13 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'otp.signingIn': 'در حال ورود...',
|
'otp.signingIn': 'در حال ورود...',
|
||||||
'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
|
'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
|
||||||
|
|
||||||
|
'categories.tooltip.expand': 'باز کردن',
|
||||||
|
'categories.tooltip.collapse': 'بستن',
|
||||||
|
'categories.select.search': 'جستجوی دستهبندیها…',
|
||||||
|
'categories.select.none': '— بدون دستهبندی —',
|
||||||
|
'categories.select.empty': 'دستهبندیای یافت نشد',
|
||||||
|
'categories.select.clear': 'پاک کردن انتخاب',
|
||||||
|
|
||||||
'common.close': 'بستن',
|
'common.close': 'بستن',
|
||||||
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
|
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
|
||||||
'common.resend': 'ارسال مجدد کد پیامکی',
|
'common.resend': 'ارسال مجدد کد پیامکی',
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Pencil, Plus, X } from 'lucide-react'
|
import { Pencil, Plus, X } from 'lucide-react'
|
||||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||||
import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice'
|
import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice'
|
||||||
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
|
import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
|
||||||
import {
|
import {
|
||||||
draftItemFromItemTemplate,
|
draftItemFromItemTemplate,
|
||||||
emptyDraftAccount,
|
emptyDraftAccount,
|
||||||
emptyDraftItem,
|
emptyDraftItem,
|
||||||
emptyDraftKeyPoint,
|
emptyDraftKeyPoint,
|
||||||
|
emptyDraftPaymentMethod,
|
||||||
} from '../utils/invoiceDraft'
|
} from '../utils/invoiceDraft'
|
||||||
import tableStyles from '../pages/BusinessesPage.module.css'
|
import tableStyles from '../pages/BusinessesPage.module.css'
|
||||||
import styles from '../pages/BusinessInvoicesPage.module.css'
|
import styles from '../pages/BusinessInvoicesPage.module.css'
|
||||||
@@ -17,15 +18,23 @@ function focusKeyPointInput(index: number) {
|
|||||||
el?.select()
|
el?.select()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusPaymentMethodInput(index: number) {
|
||||||
|
const el = document.querySelector<HTMLInputElement>(`input[data-payment-method-index="${index}"]`)
|
||||||
|
el?.focus()
|
||||||
|
el?.select()
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
itemTemplates: InvoiceItemTemplate[]
|
itemTemplates: InvoiceItemTemplate[]
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
keyPoints: DraftKeyPoint[]
|
keyPoints: DraftKeyPoint[]
|
||||||
|
paymentMethods: DraftPaymentMethod[]
|
||||||
accounts: DraftAccount[]
|
accounts: DraftAccount[]
|
||||||
selectedItemTemplateId: string
|
selectedItemTemplateId: string
|
||||||
onSelectedItemTemplateId: (id: string) => void
|
onSelectedItemTemplateId: (id: string) => void
|
||||||
onItemsChange: (items: DraftLineItem[]) => void
|
onItemsChange: (items: DraftLineItem[]) => void
|
||||||
onKeyPointsChange: (points: DraftKeyPoint[]) => void
|
onKeyPointsChange: (points: DraftKeyPoint[]) => void
|
||||||
|
onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
|
||||||
onAccountsChange: (accounts: DraftAccount[]) => void
|
onAccountsChange: (accounts: DraftAccount[]) => void
|
||||||
showItemTemplatePicker?: boolean
|
showItemTemplatePicker?: boolean
|
||||||
}
|
}
|
||||||
@@ -34,11 +43,13 @@ export function InvoiceDraftFields({
|
|||||||
itemTemplates,
|
itemTemplates,
|
||||||
items,
|
items,
|
||||||
keyPoints,
|
keyPoints,
|
||||||
|
paymentMethods,
|
||||||
accounts,
|
accounts,
|
||||||
selectedItemTemplateId,
|
selectedItemTemplateId,
|
||||||
onSelectedItemTemplateId,
|
onSelectedItemTemplateId,
|
||||||
onItemsChange,
|
onItemsChange,
|
||||||
onKeyPointsChange,
|
onKeyPointsChange,
|
||||||
|
onPaymentMethodsChange,
|
||||||
onAccountsChange,
|
onAccountsChange,
|
||||||
showItemTemplatePicker = true,
|
showItemTemplatePicker = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -239,6 +250,67 @@ export function InvoiceDraftFields({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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.blockSection}>
|
||||||
<div className={styles.blockHeader}>
|
<div className={styles.blockHeader}>
|
||||||
<h4 className={styles.blockTitle}>Account numbers</h4>
|
<h4 className={styles.blockTitle}>Account numbers</h4>
|
||||||
|
|||||||
@@ -429,6 +429,17 @@ export function BusinessInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
|
||||||
<div className={styles.blockSection}>
|
<div className={styles.blockSection}>
|
||||||
<h4 className={styles.blockTitle}>Accounts</h4>
|
<h4 className={styles.blockTitle}>Accounts</h4>
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import type { InvoiceItemTemplate } from '../types/invoice'
|
|||||||
import {
|
import {
|
||||||
buildAccountsPayload,
|
buildAccountsPayload,
|
||||||
buildKeyPointsPayload,
|
buildKeyPointsPayload,
|
||||||
|
buildPaymentMethodsPayload,
|
||||||
buildTemplateItemsPayload,
|
buildTemplateItemsPayload,
|
||||||
draftsFromInvoiceTemplate,
|
draftsFromInvoiceTemplate,
|
||||||
emptyDraftItem,
|
emptyDraftItem,
|
||||||
type DraftAccount,
|
type DraftAccount,
|
||||||
type DraftKeyPoint,
|
type DraftKeyPoint,
|
||||||
type DraftLineItem,
|
type DraftLineItem,
|
||||||
|
type DraftPaymentMethod,
|
||||||
} from '../utils/invoiceDraft'
|
} from '../utils/invoiceDraft'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import tableStyles from './BusinessesPage.module.css'
|
import tableStyles from './BusinessesPage.module.css'
|
||||||
@@ -40,6 +42,7 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
const [topText, setTopText] = useState('')
|
const [topText, setTopText] = useState('')
|
||||||
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
|
||||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -62,6 +65,7 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
setTopText(drafts.topText)
|
setTopText(drafts.topText)
|
||||||
setItems(drafts.items)
|
setItems(drafts.items)
|
||||||
setKeyPoints(drafts.keyPoints)
|
setKeyPoints(drafts.keyPoints)
|
||||||
|
setPaymentMethods(drafts.paymentMethods)
|
||||||
setAccounts(drafts.accounts)
|
setAccounts(drafts.accounts)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -95,6 +99,7 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
topText: isEmptyRichText(topText) ? undefined : topText,
|
topText: isEmptyRichText(topText) ? undefined : topText,
|
||||||
items: lineItems,
|
items: lineItems,
|
||||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||||
|
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
|
||||||
accounts: buildAccountsPayload(accounts),
|
accounts: buildAccountsPayload(accounts),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +132,7 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
{isEdit ? 'Edit invoice template' : 'Add invoice template'}
|
{isEdit ? 'Edit invoice template' : 'Add invoice template'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={pageStyles.pageSubtitle}>
|
<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.
|
invoices.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,11 +169,13 @@ export function InvoiceTemplateEditorPage() {
|
|||||||
itemTemplates={itemTemplates}
|
itemTemplates={itemTemplates}
|
||||||
items={items}
|
items={items}
|
||||||
keyPoints={keyPoints}
|
keyPoints={keyPoints}
|
||||||
|
paymentMethods={paymentMethods}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
selectedItemTemplateId={selectedItemTemplateId}
|
selectedItemTemplateId={selectedItemTemplateId}
|
||||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||||
onItemsChange={setItems}
|
onItemsChange={setItems}
|
||||||
onKeyPointsChange={setKeyPoints}
|
onKeyPointsChange={setKeyPoints}
|
||||||
|
onPaymentMethodsChange={setPaymentMethods}
|
||||||
onAccountsChange={setAccounts}
|
onAccountsChange={setAccounts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ import {
|
|||||||
buildAccountsPayload,
|
buildAccountsPayload,
|
||||||
buildKeyPointsPayload,
|
buildKeyPointsPayload,
|
||||||
buildLineItemsPayload,
|
buildLineItemsPayload,
|
||||||
|
buildPaymentMethodsPayload,
|
||||||
draftsFromInvoice,
|
draftsFromInvoice,
|
||||||
draftsFromInvoiceTemplate,
|
draftsFromInvoiceTemplate,
|
||||||
emptyDraftItem,
|
emptyDraftItem,
|
||||||
type DraftAccount,
|
type DraftAccount,
|
||||||
type DraftKeyPoint,
|
type DraftKeyPoint,
|
||||||
type DraftLineItem,
|
type DraftLineItem,
|
||||||
|
type DraftPaymentMethod,
|
||||||
} from '../utils/invoiceDraft'
|
} from '../utils/invoiceDraft'
|
||||||
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
@@ -58,6 +60,7 @@ export function IssueInvoicePage() {
|
|||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<DraftPaymentMethod[]>([])
|
||||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -107,6 +110,7 @@ export function IssueInvoicePage() {
|
|||||||
setNotes(drafts.notes)
|
setNotes(drafts.notes)
|
||||||
setDraftItems(drafts.items)
|
setDraftItems(drafts.items)
|
||||||
setKeyPoints(drafts.keyPoints)
|
setKeyPoints(drafts.keyPoints)
|
||||||
|
setPaymentMethods(drafts.paymentMethods)
|
||||||
setAccounts(drafts.accounts)
|
setAccounts(drafts.accounts)
|
||||||
setSelectedItemTemplateId('')
|
setSelectedItemTemplateId('')
|
||||||
}
|
}
|
||||||
@@ -130,6 +134,7 @@ export function IssueInvoicePage() {
|
|||||||
setNotes('')
|
setNotes('')
|
||||||
setDraftItems([emptyDraftItem()])
|
setDraftItems([emptyDraftItem()])
|
||||||
setKeyPoints([])
|
setKeyPoints([])
|
||||||
|
setPaymentMethods([])
|
||||||
setAccounts([])
|
setAccounts([])
|
||||||
setSelectedItemTemplateId('')
|
setSelectedItemTemplateId('')
|
||||||
setFormError('')
|
setFormError('')
|
||||||
@@ -150,6 +155,7 @@ export function IssueInvoicePage() {
|
|||||||
setTopText(drafts.topText)
|
setTopText(drafts.topText)
|
||||||
setDraftItems(drafts.items)
|
setDraftItems(drafts.items)
|
||||||
setKeyPoints(drafts.keyPoints)
|
setKeyPoints(drafts.keyPoints)
|
||||||
|
setPaymentMethods(drafts.paymentMethods)
|
||||||
setAccounts(drafts.accounts)
|
setAccounts(drafts.accounts)
|
||||||
setSelectedItemTemplateId('')
|
setSelectedItemTemplateId('')
|
||||||
setFormError('')
|
setFormError('')
|
||||||
@@ -176,6 +182,7 @@ export function IssueInvoicePage() {
|
|||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
invoiceTemplateId: invoiceTemplateId ?? null,
|
invoiceTemplateId: invoiceTemplateId ?? null,
|
||||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||||
|
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
|
||||||
accounts: buildAccountsPayload(accounts),
|
accounts: buildAccountsPayload(accounts),
|
||||||
})
|
})
|
||||||
showToast('Invoice updated.', 'success')
|
showToast('Invoice updated.', 'success')
|
||||||
@@ -187,6 +194,7 @@ export function IssueInvoicePage() {
|
|||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
invoiceTemplateId,
|
invoiceTemplateId,
|
||||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||||
|
paymentMethods: buildPaymentMethodsPayload(paymentMethods),
|
||||||
accounts: buildAccountsPayload(accounts),
|
accounts: buildAccountsPayload(accounts),
|
||||||
})
|
})
|
||||||
showToast('Invoice issued.', 'success')
|
showToast('Invoice issued.', 'success')
|
||||||
@@ -300,11 +308,13 @@ export function IssueInvoicePage() {
|
|||||||
itemTemplates={itemTemplates}
|
itemTemplates={itemTemplates}
|
||||||
items={draftItems}
|
items={draftItems}
|
||||||
keyPoints={keyPoints}
|
keyPoints={keyPoints}
|
||||||
|
paymentMethods={paymentMethods}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
selectedItemTemplateId={selectedItemTemplateId}
|
selectedItemTemplateId={selectedItemTemplateId}
|
||||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||||
onItemsChange={setDraftItems}
|
onItemsChange={setDraftItems}
|
||||||
onKeyPointsChange={setKeyPoints}
|
onKeyPointsChange={setKeyPoints}
|
||||||
|
onPaymentMethodsChange={setPaymentMethods}
|
||||||
onAccountsChange={setAccounts}
|
onAccountsChange={setAccounts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const COPY: Record<
|
|||||||
subtotal: string
|
subtotal: string
|
||||||
total: string
|
total: string
|
||||||
keyPoints: string
|
keyPoints: string
|
||||||
|
paymentMethods: string
|
||||||
accounts: string
|
accounts: string
|
||||||
card: string
|
card: string
|
||||||
iban: string
|
iban: string
|
||||||
@@ -45,6 +46,7 @@ const COPY: Record<
|
|||||||
subtotal: 'Subtotal',
|
subtotal: 'Subtotal',
|
||||||
total: 'Total',
|
total: 'Total',
|
||||||
keyPoints: 'Key points',
|
keyPoints: 'Key points',
|
||||||
|
paymentMethods: 'Payment methods',
|
||||||
accounts: 'Account numbers',
|
accounts: 'Account numbers',
|
||||||
card: 'Card',
|
card: 'Card',
|
||||||
iban: 'IBAN',
|
iban: 'IBAN',
|
||||||
@@ -66,6 +68,7 @@ const COPY: Record<
|
|||||||
subtotal: 'جمع جزء',
|
subtotal: 'جمع جزء',
|
||||||
total: 'مبلغ کل',
|
total: 'مبلغ کل',
|
||||||
keyPoints: 'نکات کلیدی',
|
keyPoints: 'نکات کلیدی',
|
||||||
|
paymentMethods: 'نحوه ی پرداخت',
|
||||||
accounts: 'شماره حسابها',
|
accounts: 'شماره حسابها',
|
||||||
card: 'کارت',
|
card: 'کارت',
|
||||||
iban: 'شبا',
|
iban: 'شبا',
|
||||||
@@ -240,6 +243,17 @@ export function PublicInvoicePage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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 ? (
|
{(invoice.accounts?.length ?? 0) > 0 ? (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>{t.accounts}</h2>
|
<h2 className={styles.sectionTitle}>{t.accounts}</h2>
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export interface InvoiceKeyPoint {
|
|||||||
sortOrder: number
|
sortOrder: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvoicePaymentMethod {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface InvoiceAccount {
|
export interface InvoiceAccount {
|
||||||
id: string
|
id: string
|
||||||
bankName: string
|
bankName: string
|
||||||
@@ -79,6 +85,7 @@ export interface Invoice {
|
|||||||
} | null
|
} | null
|
||||||
items?: InvoiceItem[]
|
items?: InvoiceItem[]
|
||||||
keyPoints?: InvoiceKeyPoint[]
|
keyPoints?: InvoiceKeyPoint[]
|
||||||
|
paymentMethods?: InvoicePaymentMethod[]
|
||||||
accounts?: InvoiceAccount[]
|
accounts?: InvoiceAccount[]
|
||||||
subtotal?: number
|
subtotal?: number
|
||||||
total?: number
|
total?: number
|
||||||
@@ -102,6 +109,7 @@ export type PublicInvoice = {
|
|||||||
}
|
}
|
||||||
items?: InvoiceItem[]
|
items?: InvoiceItem[]
|
||||||
keyPoints?: InvoiceKeyPoint[]
|
keyPoints?: InvoiceKeyPoint[]
|
||||||
|
paymentMethods?: InvoicePaymentMethod[]
|
||||||
accounts?: InvoiceAccount[]
|
accounts?: InvoiceAccount[]
|
||||||
subtotal?: number
|
subtotal?: number
|
||||||
total?: number
|
total?: number
|
||||||
@@ -122,6 +130,10 @@ export interface InvoiceKeyPointInput {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvoicePaymentMethodInput {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface InvoiceAccountInput {
|
export interface InvoiceAccountInput {
|
||||||
bankName: string
|
bankName: string
|
||||||
accountHolderName?: string
|
accountHolderName?: string
|
||||||
@@ -137,6 +149,7 @@ export interface CreateInvoicePayload {
|
|||||||
notes?: string
|
notes?: string
|
||||||
invoiceTemplateId?: string
|
invoiceTemplateId?: string
|
||||||
keyPoints?: InvoiceKeyPointInput[]
|
keyPoints?: InvoiceKeyPointInput[]
|
||||||
|
paymentMethods?: InvoicePaymentMethodInput[]
|
||||||
accounts?: InvoiceAccountInput[]
|
accounts?: InvoiceAccountInput[]
|
||||||
status?: InvoiceStatus
|
status?: InvoiceStatus
|
||||||
}
|
}
|
||||||
@@ -149,6 +162,7 @@ export type UpdateInvoicePayload = {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
invoiceTemplateId?: string | null
|
invoiceTemplateId?: string | null
|
||||||
keyPoints?: InvoiceKeyPointInput[]
|
keyPoints?: InvoiceKeyPointInput[]
|
||||||
|
paymentMethods?: InvoicePaymentMethodInput[]
|
||||||
accounts?: InvoiceAccountInput[]
|
accounts?: InvoiceAccountInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +190,7 @@ export interface InvoiceTemplate {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
items: InvoiceTemplateItem[]
|
items: InvoiceTemplateItem[]
|
||||||
keyPoints: InvoiceKeyPoint[]
|
keyPoints: InvoiceKeyPoint[]
|
||||||
|
paymentMethods: InvoicePaymentMethod[]
|
||||||
accounts: InvoiceAccount[]
|
accounts: InvoiceAccount[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +209,7 @@ export interface CreateInvoiceTemplatePayload {
|
|||||||
topText?: string
|
topText?: string
|
||||||
items: InvoiceTemplateItemInput[]
|
items: InvoiceTemplateItemInput[]
|
||||||
keyPoints?: InvoiceKeyPointInput[]
|
keyPoints?: InvoiceKeyPointInput[]
|
||||||
|
paymentMethods?: InvoicePaymentMethodInput[]
|
||||||
accounts?: InvoiceAccountInput[]
|
accounts?: InvoiceAccountInput[]
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
InvoiceItemInput,
|
InvoiceItemInput,
|
||||||
InvoiceItemTemplate,
|
InvoiceItemTemplate,
|
||||||
InvoiceKeyPointInput,
|
InvoiceKeyPointInput,
|
||||||
|
InvoicePaymentMethodInput,
|
||||||
InvoiceTemplate,
|
InvoiceTemplate,
|
||||||
InvoiceTemplateItemInput,
|
InvoiceTemplateItemInput,
|
||||||
} from '../types/invoice'
|
} from '../types/invoice'
|
||||||
@@ -21,6 +22,8 @@ export type DraftLineItem = {
|
|||||||
|
|
||||||
export type DraftKeyPoint = { key: string; text: string }
|
export type DraftKeyPoint = { key: string; text: string }
|
||||||
|
|
||||||
|
export type DraftPaymentMethod = { key: string; text: string }
|
||||||
|
|
||||||
export type DraftAccount = {
|
export type DraftAccount = {
|
||||||
key: string
|
key: string
|
||||||
bankName: string
|
bankName: string
|
||||||
@@ -49,6 +52,10 @@ export function emptyDraftKeyPoint(): DraftKeyPoint {
|
|||||||
return { key: newKey(), text: '' }
|
return { key: newKey(), text: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function emptyDraftPaymentMethod(): DraftPaymentMethod {
|
||||||
|
return { key: newKey(), text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyDraftAccount(): DraftAccount {
|
export function emptyDraftAccount(): DraftAccount {
|
||||||
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
|
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
|
||||||
}
|
}
|
||||||
@@ -74,6 +81,7 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
|
|||||||
topText: string
|
topText: string
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
keyPoints: DraftKeyPoint[]
|
keyPoints: DraftKeyPoint[]
|
||||||
|
paymentMethods: DraftPaymentMethod[]
|
||||||
accounts: DraftAccount[]
|
accounts: DraftAccount[]
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
@@ -99,6 +107,10 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
|
|||||||
template.keyPoints.length > 0
|
template.keyPoints.length > 0
|
||||||
? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text }))
|
? 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:
|
accounts:
|
||||||
template.accounts.length > 0
|
template.accounts.length > 0
|
||||||
? template.accounts.map((acc) => ({
|
? template.accounts.map((acc) => ({
|
||||||
@@ -126,6 +138,7 @@ export function draftsFromInvoice(invoice: {
|
|||||||
discountedPrice: number | null
|
discountedPrice: number | null
|
||||||
}>
|
}>
|
||||||
keyPoints?: Array<{ text: string }>
|
keyPoints?: Array<{ text: string }>
|
||||||
|
paymentMethods?: Array<{ text: string }>
|
||||||
accounts?: Array<{
|
accounts?: Array<{
|
||||||
bankName: string
|
bankName: string
|
||||||
accountHolderName: string | null
|
accountHolderName: string | null
|
||||||
@@ -138,6 +151,7 @@ export function draftsFromInvoice(invoice: {
|
|||||||
notes: string
|
notes: string
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
keyPoints: DraftKeyPoint[]
|
keyPoints: DraftKeyPoint[]
|
||||||
|
paymentMethods: DraftPaymentMethod[]
|
||||||
accounts: DraftAccount[]
|
accounts: DraftAccount[]
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
@@ -164,6 +178,10 @@ export function draftsFromInvoice(invoice: {
|
|||||||
(invoice.keyPoints?.length ?? 0) > 0
|
(invoice.keyPoints?.length ?? 0) > 0
|
||||||
? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text }))
|
? 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:
|
accounts:
|
||||||
(invoice.accounts?.length ?? 0) > 0
|
(invoice.accounts?.length ?? 0) > 0
|
||||||
? invoice.accounts!.map((acc) => ({
|
? invoice.accounts!.map((acc) => ({
|
||||||
@@ -223,6 +241,15 @@ export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointI
|
|||||||
.map((text) => ({ text }))
|
.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[] {
|
export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] {
|
||||||
return accounts
|
return accounts
|
||||||
.map((acc) => ({
|
.map((acc) => ({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export interface DashboardDocumentTitleParts {
|
|||||||
pageLabels?: string[]
|
pageLabels?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pattern: `{businessName} - {dashboardName} · {page}` */
|
/** Pattern: `{dashboardName} · {page} - {businessName}` */
|
||||||
export function formatDashboardDocumentTitle({
|
export function formatDashboardDocumentTitle({
|
||||||
businessName,
|
businessName,
|
||||||
dashboardName,
|
dashboardName,
|
||||||
@@ -13,12 +13,10 @@ export function formatDashboardDocumentTitle({
|
|||||||
const business = businessName.trim() || 'Store'
|
const business = businessName.trim() || 'Store'
|
||||||
const dashboard = dashboardName.trim()
|
const dashboard = dashboardName.trim()
|
||||||
const pages = pageLabels.map((label) => label.trim()).filter(Boolean)
|
const pages = pageLabels.map((label) => label.trim()).filter(Boolean)
|
||||||
|
const pagePart =
|
||||||
|
pages.length === 0 ? dashboard : `${dashboard} · ${pages.join(' · ')}`
|
||||||
|
|
||||||
if (pages.length === 0) {
|
return `${pagePart} - ${business}`
|
||||||
return `${business} - ${dashboard}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${business} - ${dashboard} · ${pages.join(' · ')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteTitleRule {
|
export interface RouteTitleRule {
|
||||||
|
|||||||
Reference in New Issue
Block a user