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