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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-21 20:02:05 +03:30
co-authored by Cursor
parent 838e84056e
commit 8465a29ce1
40 changed files with 1192 additions and 586 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { 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>
+2 -1
View File
@@ -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 {
+14 -9
View File
@@ -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 اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتی‌متر یا کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن.
روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند. روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.
برچسب‌ها و گزینه‌ها را به فارسی بنویس. برچسب‌ها را به فارسی بنویس.
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز می‌کند و فرم را بر اساس مشخصات واقعی آن می‌سازد).` در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز می‌کند و فرم را بر اساس مشخصات واقعی آن می‌سازد).`
} }
+16 -2
View File
@@ -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': 'متن بالای فاکتور',
+6 -6
View File
@@ -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')}
+6 -6
View File
@@ -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,8 +54,7 @@ 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,8 +60,7 @@ 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}
/> />
+11
View File
@@ -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}>
+15
View File
@@ -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
} }
+27
View File
@@ -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>
)
}
+14
View File
@@ -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>
+16
View File
@@ -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 {