diff --git a/apps/business/src/components/AddProductByAiModal.tsx b/apps/business/src/components/AddProductByAiModal.tsx index b38bd5c..4a330f0 100644 --- a/apps/business/src/components/AddProductByAiModal.tsx +++ b/apps/business/src/components/AddProductByAiModal.tsx @@ -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({
void handleSubmit(e)}>
-
diff --git a/apps/business/src/components/BrandModal.tsx b/apps/business/src/components/BrandModal.tsx index ec0c88e..24dd8cd 100644 --- a/apps/business/src/components/BrandModal.tsx +++ b/apps/business/src/components/BrandModal.tsx @@ -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)} diff --git a/apps/business/src/components/CategoryModal.module.css b/apps/business/src/components/CategoryModal.module.css index d068f94..8094908 100644 --- a/apps/business/src/components/CategoryModal.module.css +++ b/apps/business/src/components/CategoryModal.module.css @@ -93,6 +93,7 @@ display: flex; flex-direction: column; gap: 4px; + position: relative; } .field label { diff --git a/apps/business/src/components/CategoryModal.tsx b/apps/business/src/components/CategoryModal.tsx index 59c1f0e..2d88a6f 100644 --- a/apps/business/src/components/CategoryModal.tsx +++ b/apps/business/src/components/CategoryModal.tsx @@ -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(
-
@@ -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)} diff --git a/apps/business/src/components/CategoryRow.module.css b/apps/business/src/components/CategoryRow.module.css index 8f46772..fcf2c1c 100644 --- a/apps/business/src/components/CategoryRow.module.css +++ b/apps/business/src/components/CategoryRow.module.css @@ -75,6 +75,8 @@ font-size: 15px; font-weight: 600; color: var(--text-primary); + direction: ltr; + unicode-bidi: isolate; } .separator { diff --git a/apps/business/src/components/CategorySearchSelect.tsx b/apps/business/src/components/CategorySearchSelect.tsx index 9bd358e..e33140f 100644 --- a/apps/business/src/components/CategorySearchSelect.tsx +++ b/apps/business/src/components/CategorySearchSelect.tsx @@ -1,9 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown, Search, X } from 'lucide-react' -import { useLocale } from '@meshkee/dashboard-ui' -import { useT } from '../i18n/useT' +import { useMemo } from 'react' import type { UserProductCategoryOption } from '../services/userProductsService' -import styles from './CategorySearchSelect.module.css' +import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect' export interface FlatCategoryOption extends UserProductCategoryOption { depth: number @@ -20,13 +17,7 @@ interface CategorySearchSelectProps { dropdownVariant?: 'overlay' | 'inFlow' } -function categoryLabel(category: UserProductCategoryOption, isFa: boolean) { - if (isFa) { - return category.nameFa?.trim() || category.name - } - return category.name || category.nameFa?.trim() || '' -} - +/** @deprecated Prefer CategoryTreeSelect; kept for 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() - - for (const item of flat) { - const nameEn = item.name.toLowerCase() - const nameFa = (item.nameFa || '').toLowerCase() - if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) { - matchedIds.add(item.id) - let parentId = item.parentId - while (parentId) { - matchedIds.add(parentId) - parentId = byId.get(parentId)?.parentId ?? null - } - } - } - - return flat.filter((item) => matchedIds.has(item.id)) -} - -function isLastChildInGroup(items: FlatCategoryOption[], index: number): boolean { - const current = items[index] - if (current.depth === 0) return false - const next = items[index + 1] - return !next || next.depth < current.depth -} - -function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) { - if (depth === 0) return null - - return ( -
- {Array.from({ length: depth }, (_, level) => { - const isBranchLevel = level === depth - 1 - return ( - - ) - })} -
- ) +function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] { + return options.map((option) => ({ + id: option.id, + parentId: option.parentId, + nameEn: option.name, + nameFa: option.nameFa?.trim() || '', + })) } +/** 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(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 ( -
-
- - { - setQuery(e.target.value) - if (!open) setOpen(true) - }} - onFocus={() => { - if (!disabled) setOpen(true) - }} - autoComplete="off" - role="combobox" - aria-expanded={open} - aria-autocomplete="list" - /> - {allowEmpty && value && !open && !disabled ? ( - - ) : null} - -
- - {open && !disabled ? ( -
    - {filtered.length === 0 ? ( -
  • {t('customerProducts.form.fields.noCategories')}
  • - ) : ( - filtered.map((option, index) => { - const isLastChild = isLastChildInGroup(filtered, index) - return ( -
  • - -
  • - ) - }) - )} -
- ) : null} -
+ ) } diff --git a/apps/business/src/components/CategoryTreeSelect.module.css b/apps/business/src/components/CategoryTreeSelect.module.css index 8c97875..414d1c2 100644 --- a/apps/business/src/components/CategoryTreeSelect.module.css +++ b/apps/business/src/components/CategoryTreeSelect.module.css @@ -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; diff --git a/apps/business/src/components/CategoryTreeSelect.tsx b/apps/business/src/components/CategoryTreeSelect.tsx index 8a2d473..46fd975 100644 --- a/apps/business/src/components/CategoryTreeSelect.tsx +++ b/apps/business/src/components/CategoryTreeSelect.tsx @@ -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 | null { +function visibleIdsForQuery( + categories: CategoryTreeItem[], + query: string, +): Set | 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 @@ -114,9 +146,7 @@ function TreeNodes({ aria-expanded={expanded} onClick={() => onToggle(category.id)} > - - - + {expanded ? : } ) : ( @@ -127,9 +157,19 @@ function TreeNodes({ className={styles.selectBtn} onClick={() => onSelect(category.id)} > - {primary} + + {primary} + {secondary ? ( - + · {secondary} ) : 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 ( -
+
- {value && !open && !disabled ? ( + {allowEmpty && value && !open && !disabled ? ( + ) : null} {!hasVisible ? (

{t('categories.select.empty')}

) : ( diff --git a/apps/business/src/components/DailyActivityChart.module.css b/apps/business/src/components/DailyActivityChart.module.css index c2f1b09..c97ab3b 100644 --- a/apps/business/src/components/DailyActivityChart.module.css +++ b/apps/business/src/components/DailyActivityChart.module.css @@ -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 { diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.tsx b/apps/business/src/components/GenerateWebsiteLinkModal.tsx index 7cc85b4..d4b7c35 100644 --- a/apps/business/src/components/GenerateWebsiteLinkModal.tsx +++ b/apps/business/src/components/GenerateWebsiteLinkModal.tsx @@ -274,7 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs value={categoryId} onChange={setCategoryId} placeholder={t('website.linkGenerator.pickCategory')} - dropdownVariant="inFlow" + allowEmpty={false} /> )}
diff --git a/apps/business/src/components/InvoiceDraftFields.tsx b/apps/business/src/components/InvoiceDraftFields.tsx index 7a0935c..e1a5c07 100644 --- a/apps/business/src/components/InvoiceDraftFields.tsx +++ b/apps/business/src/components/InvoiceDraftFields.tsx @@ -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(`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({ )}
+
+
+

{t('invoiceDraft.paymentMethods')}

+ +
+ {paymentMethods.length === 0 ? ( +

{t('invoiceDraft.noPaymentMethods')}

+ ) : ( +
+ {paymentMethods.map((method, index) => ( +
+
+ + 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 })} + /> +
+ +
+ ))} +
+ )} +
+

{t('invoiceDraft.accounts')}

diff --git a/apps/business/src/components/PortfolioCard.tsx b/apps/business/src/components/PortfolioCard.tsx index 2bfedae..b9c2078 100644 --- a/apps/business/src/components/PortfolioCard.tsx +++ b/apps/business/src/components/PortfolioCard.tsx @@ -76,7 +76,7 @@ export function PortfolioCard({ ) : null} {titleEn ? ( -

+

{titleEn}

) : null} diff --git a/apps/business/src/components/SearchableSelect.module.css b/apps/business/src/components/SearchableSelect.module.css index 6d48491..55ef872 100644 --- a/apps/business/src/components/SearchableSelect.module.css +++ b/apps/business/src/components/SearchableSelect.module.css @@ -227,6 +227,8 @@ .optionEn { font-weight: 500; text-align: start; + direction: ltr; + unicode-bidi: isolate; } .optionEn[dir='rtl'] { diff --git a/apps/business/src/components/SearchableSelect.tsx b/apps/business/src/components/SearchableSelect.tsx index 7f0167f..abad31b 100644 --- a/apps/business/src/components/SearchableSelect.tsx +++ b/apps/business/src/components/SearchableSelect.tsx @@ -195,13 +195,17 @@ export function SearchableSelect({ {cat.nameEn ? ( <> · - {cat.nameEn} + + {cat.nameEn} + ) : null} ) : ( <> - {cat.nameEn} + + {cat.nameEn} + {cat.nameFa ? ( <> · diff --git a/apps/business/src/components/TechnicalFormAiPromptModal.tsx b/apps/business/src/components/TechnicalFormAiPromptModal.tsx index 74918fc..455440e 100644 --- a/apps/business/src/components/TechnicalFormAiPromptModal.tsx +++ b/apps/business/src/components/TechnicalFormAiPromptModal.tsx @@ -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 اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتی‌متر یا کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن. روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند. -برچسب‌ها و گزینه‌ها را به فارسی بنویس. +برچسب‌ها را به فارسی بنویس. در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز می‌کند و فرم را بر اساس مشخصات واقعی آن می‌سازد).` } diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index cbd23fa..91c4650 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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 = { '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 = { 'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور', 'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور', 'invoiceTemplateEditor.subtitle': - 'نام، متن بالا، آیتم‌ها، نکات کلیدی و حساب‌های بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.', + 'نام، متن بالا، آیتم‌ها، نکات کلیدی، نحوه ی پرداخت و حساب‌های بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.', 'invoiceTemplateEditor.name': 'نام', 'invoiceTemplateEditor.namePlaceholder': 'مثلاً بسته استاندارد سایت', 'invoiceTemplateEditor.topText': 'متن بالای فاکتور', diff --git a/apps/business/src/pages/AddNewBlogPage.tsx b/apps/business/src/pages/AddNewBlogPage.tsx index e358fb0..6361cba 100644 --- a/apps/business/src/pages/AddNewBlogPage.tsx +++ b/apps/business/src/pages/AddNewBlogPage.tsx @@ -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() {
-
diff --git a/apps/business/src/pages/AddNewInstructionPage.tsx b/apps/business/src/pages/AddNewInstructionPage.tsx index edfceaa..8bb9071 100644 --- a/apps/business/src/pages/AddNewInstructionPage.tsx +++ b/apps/business/src/pages/AddNewInstructionPage.tsx @@ -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() {
-
diff --git a/apps/business/src/pages/AddNewPortfolioPage.tsx b/apps/business/src/pages/AddNewPortfolioPage.tsx index 78b211c..e14b2b8 100644 --- a/apps/business/src/pages/AddNewPortfolioPage.tsx +++ b/apps/business/src/pages/AddNewPortfolioPage.tsx @@ -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() {
-
diff --git a/apps/business/src/pages/AddNewProductPage.tsx b/apps/business/src/pages/AddNewProductPage.tsx index 1f1380a..bbbb189 100644 --- a/apps/business/src/pages/AddNewProductPage.tsx +++ b/apps/business/src/pages/AddNewProductPage.tsx @@ -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() {
- { const controller = new AbortController() @@ -218,11 +216,13 @@ export function AddNewVideoPage() {
-
diff --git a/apps/business/src/pages/AddNewWorkshopPage.tsx b/apps/business/src/pages/AddNewWorkshopPage.tsx index 944d9ba..cf81e05 100644 --- a/apps/business/src/pages/AddNewWorkshopPage.tsx +++ b/apps/business/src/pages/AddNewWorkshopPage.tsx @@ -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() {
-
diff --git a/apps/business/src/pages/InvoiceTemplateEditorPage.tsx b/apps/business/src/pages/InvoiceTemplateEditorPage.tsx index f874696..95c2dab 100644 --- a/apps/business/src/pages/InvoiceTemplateEditorPage.tsx +++ b/apps/business/src/pages/InvoiceTemplateEditorPage.tsx @@ -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([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) + const [paymentMethods, setPaymentMethods] = useState([]) const [accounts, setAccounts] = useState([]) 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} /> diff --git a/apps/business/src/pages/InvoicesPage.tsx b/apps/business/src/pages/InvoicesPage.tsx index 2e33bd3..30aeb53 100644 --- a/apps/business/src/pages/InvoicesPage.tsx +++ b/apps/business/src/pages/InvoicesPage.tsx @@ -535,6 +535,17 @@ export function InvoicesPage() {
) : null} + {(detailInvoice.paymentMethods?.length ?? 0) > 0 ? ( +
+

{t('invoiceDraft.paymentMethods')}

+
    + {detailInvoice.paymentMethods!.map((pm) => ( +
  • {pm.text}
  • + ))} +
+
+ ) : null} + {(detailInvoice.accounts?.length ?? 0) > 0 ? (

{t('invoiceDraft.accounts')}

diff --git a/apps/business/src/pages/IssueInvoicePage.tsx b/apps/business/src/pages/IssueInvoicePage.tsx index 72d25dc..27516b2 100644 --- a/apps/business/src/pages/IssueInvoicePage.tsx +++ b/apps/business/src/pages/IssueInvoicePage.tsx @@ -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([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) + const [paymentMethods, setPaymentMethods] = useState([]) const [accounts, setAccounts] = useState([]) 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={
diff --git a/apps/business/src/types/invoice.ts b/apps/business/src/types/invoice.ts index 9dffa13..ce8d4e0 100644 --- a/apps/business/src/types/invoice.ts +++ b/apps/business/src/types/invoice.ts @@ -35,6 +35,12 @@ export interface InvoiceKeyPoint { sortOrder: number } +export interface InvoicePaymentMethod { + id: string + text: string + sortOrder: number +} + export interface InvoiceAccount { id: string bankName: string @@ -79,6 +85,7 @@ export interface Invoice { } | null items?: InvoiceItem[] keyPoints?: InvoiceKeyPoint[] + paymentMethods?: InvoicePaymentMethod[] accounts?: InvoiceAccount[] subtotal?: number total?: number @@ -98,6 +105,10 @@ export interface InvoiceKeyPointInput { text: string } +export interface InvoicePaymentMethodInput { + text: string +} + export interface InvoiceAccountInput { bankName: string accountHolderName?: string @@ -114,6 +125,7 @@ export interface CreateInvoicePayload { notes?: string invoiceTemplateId?: string keyPoints?: InvoiceKeyPointInput[] + paymentMethods?: InvoicePaymentMethodInput[] accounts?: InvoiceAccountInput[] status?: InvoiceStatus } @@ -126,6 +138,7 @@ export type UpdateInvoicePayload = { notes?: string | null invoiceTemplateId?: string | null keyPoints?: InvoiceKeyPointInput[] + paymentMethods?: InvoicePaymentMethodInput[] accounts?: InvoiceAccountInput[] } @@ -153,6 +166,7 @@ export interface InvoiceTemplate { updatedAt: string items: InvoiceTemplateItem[] keyPoints: InvoiceKeyPoint[] + paymentMethods: InvoicePaymentMethod[] accounts: InvoiceAccount[] } @@ -171,6 +185,7 @@ export interface CreateInvoiceTemplatePayload { topText?: string items: InvoiceTemplateItemInput[] keyPoints?: InvoiceKeyPointInput[] + paymentMethods?: InvoicePaymentMethodInput[] accounts?: InvoiceAccountInput[] sortOrder?: number } diff --git a/apps/business/src/utils/invoiceDraft.ts b/apps/business/src/utils/invoiceDraft.ts index 135f15b..53376dc 100644 --- a/apps/business/src/utils/invoiceDraft.ts +++ b/apps/business/src/utils/invoiceDraft.ts @@ -5,6 +5,7 @@ import type { InvoiceItemInput, InvoiceItemTemplate, InvoiceKeyPointInput, + InvoicePaymentMethodInput, InvoiceTemplate, InvoiceTemplateItemInput, } from '../types/invoice' @@ -40,6 +41,8 @@ export type DraftLineItem = { export type DraftKeyPoint = { key: string; text: string } +export type DraftPaymentMethod = { key: string; text: string } + export type DraftAccount = { key: string bankName: string @@ -68,6 +71,10 @@ export function emptyDraftKeyPoint(): DraftKeyPoint { return { key: newKey(), text: '' } } +export function emptyDraftPaymentMethod(): DraftPaymentMethod { + return { key: newKey(), text: '' } +} + export function emptyDraftAccount(): DraftAccount { return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' } } @@ -93,6 +100,7 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): { topText: string items: DraftLineItem[] keyPoints: DraftKeyPoint[] + paymentMethods: DraftPaymentMethod[] accounts: DraftAccount[] } { return { @@ -118,6 +126,10 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): { template.keyPoints.length > 0 ? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text })) : [], + paymentMethods: + (template.paymentMethods?.length ?? 0) > 0 + ? template.paymentMethods.map((pm) => ({ key: newKey(), text: pm.text })) + : [], accounts: template.accounts.length > 0 ? template.accounts.map((acc) => ({ @@ -145,6 +157,7 @@ export function draftsFromInvoice(invoice: { discountedPrice: number | null }> keyPoints?: Array<{ text: string }> + paymentMethods?: Array<{ text: string }> accounts?: Array<{ bankName: string accountHolderName: string | null @@ -157,6 +170,7 @@ export function draftsFromInvoice(invoice: { notes: string items: DraftLineItem[] keyPoints: DraftKeyPoint[] + paymentMethods: DraftPaymentMethod[] accounts: DraftAccount[] } { return { @@ -183,6 +197,10 @@ export function draftsFromInvoice(invoice: { (invoice.keyPoints?.length ?? 0) > 0 ? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text })) : [], + paymentMethods: + (invoice.paymentMethods?.length ?? 0) > 0 + ? invoice.paymentMethods!.map((pm) => ({ key: newKey(), text: pm.text })) + : [], accounts: (invoice.accounts?.length ?? 0) > 0 ? invoice.accounts!.map((acc) => ({ @@ -248,6 +266,15 @@ export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointI .map((text) => ({ text })) } +export function buildPaymentMethodsPayload( + methods: DraftPaymentMethod[], +): InvoicePaymentMethodInput[] { + return methods + .map((m) => m.text.trim()) + .filter(Boolean) + .map((text) => ({ text })) +} + export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] { return accounts .map((acc) => ({ diff --git a/apps/customer/src/components/CategorySearchSelect.tsx b/apps/customer/src/components/CategorySearchSelect.tsx index c9b9433..b462908 100644 --- a/apps/customer/src/components/CategorySearchSelect.tsx +++ b/apps/customer/src/components/CategorySearchSelect.tsx @@ -1,9 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown, Search, X } from 'lucide-react' -import { useLocale } from '@meshkee/dashboard-ui' -import { useT } from '../i18n/useT' +import { useMemo } from 'react' import type { UserProductCategoryOption } from '../services/userProductsService' -import styles from './CategorySearchSelect.module.css' +import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect' export interface FlatCategoryOption extends UserProductCategoryOption { depth: number @@ -20,13 +17,7 @@ interface CategorySearchSelectProps { dropdownVariant?: 'overlay' | 'inFlow' } -function categoryLabel(category: UserProductCategoryOption, isFa: boolean) { - if (isFa) { - return category.nameFa?.trim() || category.name - } - return category.name || category.nameFa?.trim() || '' -} - +/** @deprecated Prefer CategoryTreeSelect; kept for callers that flatten options. */ export function flattenCategoryTree( categories: UserProductCategoryOption[], ): FlatCategoryOption[] { @@ -40,10 +31,7 @@ export function flattenCategoryTree( } for (const list of byParent.values()) { - list.sort((a, b) => { - const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa') - return aLabel - }) + list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')) } const result: FlatCategoryOption[] = [] @@ -52,6 +40,7 @@ export function flattenCategoryTree( function walk(parentId: string | null, depth: number) { const children = byParent.get(parentId) ?? [] for (const child of children) { + if (!ids.has(child.id)) continue result.push({ ...child, depth }) walk(child.id, depth + 1) } @@ -59,75 +48,26 @@ export function flattenCategoryTree( walk(null, 0) + const rooted = new Set(result.map((item) => item.id)) for (const category of categories) { - if (category.parentId && !ids.has(category.parentId)) { - if (!result.some((item) => item.id === category.id)) { - result.push({ ...category, depth: 0 }) - walk(category.id, 1) - } + if (!rooted.has(category.id)) { + result.push({ ...category, depth: 0 }) } } return result } -function filterCategoryTree( - flat: FlatCategoryOption[], - query: string, -): FlatCategoryOption[] { - const q = query.trim().toLowerCase() - if (!q) return flat - - const byId = new Map(flat.map((item) => [item.id, item])) - const matchedIds = new Set() - - for (const item of flat) { - const nameEn = item.name.toLowerCase() - const nameFa = (item.nameFa || '').toLowerCase() - if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) { - matchedIds.add(item.id) - let parentId = item.parentId - while (parentId) { - matchedIds.add(parentId) - parentId = byId.get(parentId)?.parentId ?? null - } - } - } - - return flat.filter((item) => matchedIds.has(item.id)) -} - -function isLastChildInGroup(items: FlatCategoryOption[], index: number): boolean { - const current = items[index] - if (current.depth === 0) return false - const next = items[index + 1] - return !next || next.depth < current.depth -} - -function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) { - if (depth === 0) return null - - return ( -
- {Array.from({ length: depth }, (_, level) => { - const isBranchLevel = level === depth - 1 - return ( - - ) - })} -
- ) +function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] { + return options.map((option) => ({ + id: option.id, + parentId: option.parentId, + nameEn: option.name, + nameFa: option.nameFa?.trim() || '', + })) } +/** Marketplace categories — same collapsible tree UX as business dashboards. */ export function CategorySearchSelect({ options, value, @@ -138,159 +78,18 @@ export function CategorySearchSelect({ allowEmpty = false, dropdownVariant = 'overlay', }: CategorySearchSelectProps) { - const t = useT() - const { locale } = useLocale() - const isFa = locale === 'fa' - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const containerRef = useRef(null) - - const flat = useMemo(() => flattenCategoryTree(options), [options]) - const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query]) - - const selected = flat.find((option) => option.id === value) - const selectedLabel = selected ? categoryLabel(selected, isFa) : '' - const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCategory') - - useEffect(() => { - if (!open) return - - function onClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false) - setQuery('') - } - } - - document.addEventListener('mousedown', onClickOutside) - return () => document.removeEventListener('mousedown', onClickOutside) - }, [open]) - - useEffect(() => { - if (disabled) { - setOpen(false) - setQuery('') - } - }, [disabled]) - - function selectOption(nextId: string) { - onChange(nextId) - setOpen(false) - setQuery('') - } - - const dropdownClassName = [ - styles.dropdown, - dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay, - ] - .filter(Boolean) - .join(' ') + const categories = useMemo(() => toTreeItems(options), [options]) return ( -
-
- - { - setQuery(e.target.value) - if (!open) setOpen(true) - }} - onFocus={() => { - if (!disabled) setOpen(true) - }} - autoComplete="off" - role="combobox" - aria-expanded={open} - aria-autocomplete="list" - /> - {allowEmpty && value && !open && !disabled ? ( - - ) : null} - -
- - {open && !disabled ? ( -
    - {filtered.length === 0 ? ( -
  • {t('myProducts.fields.noCategories')}
  • - ) : ( - filtered.map((option, index) => { - const isLastChild = isLastChildInGroup(filtered, index) - return ( -
  • - -
  • - ) - }) - )} -
- ) : null} -
+ ) } diff --git a/apps/customer/src/components/CategoryTree.module.css b/apps/customer/src/components/CategoryTree.module.css new file mode 100644 index 0000000..7fe963f --- /dev/null +++ b/apps/customer/src/components/CategoryTree.module.css @@ -0,0 +1,18 @@ +.childrenWrap { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.28s ease; +} + +.childrenWrap.expanded { + grid-template-rows: 1fr; +} + +.childrenInner { + overflow: hidden; + margin-inline-start: 28px; + margin-bottom: 10px; + padding-inline-start: 22px; + padding-inline-end: 8px; + border-inline-start: 1px solid rgba(148, 163, 184, 0.55); +} diff --git a/apps/customer/src/components/CategoryTreeSelect.module.css b/apps/customer/src/components/CategoryTreeSelect.module.css new file mode 100644 index 0000000..414d1c2 --- /dev/null +++ b/apps/customer/src/components/CategoryTreeSelect.module.css @@ -0,0 +1,218 @@ +.wrapper { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; +} + +.wrapperOpen { + z-index: 130; +} + +.inputWrap { + display: flex; + align-items: center; + gap: 6px; + min-height: var(--field-height); + padding: 0 var(--field-padding-x); + background: var(--surface); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} + +.inputWrapOpen, +.inputWrap:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.searchIcon { + flex-shrink: 0; + color: var(--text-muted); +} + +.input { + flex: 1; + min-width: 0; + padding: var(--field-padding-y) 0; + font-size: var(--field-font-size); + font-family: var(--font-ui); + line-height: 1.4; + color: var(--text-primary); + background: transparent; + border: none; + outline: none; +} + +.input::placeholder { + font-family: var(--font-ui); + color: var(--text-muted); + opacity: 1; +} + +.clearBtn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + color: var(--text-muted); + transition: background 0.2s, color 0.2s; +} + +.clearBtn:hover { + background: rgba(148, 163, 184, 0.2); + color: var(--text-secondary); +} + +.fieldChevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.fieldChevronOpen { + transform: rotate(180deg); +} + +.panel { + max-height: min(320px, 50vh); + overflow-y: auto; + padding: 4px; + background: var(--elevated-surface); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14); +} + +.panelOverlay { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 120; +} + +.panelInFlow { + position: static; + margin-top: 6px; + max-height: none; + overflow: visible; + z-index: auto; +} + +.noneOption { + width: 100%; + padding: 8px 10px; + margin-bottom: 4px; + border-radius: 8px; + font-size: var(--field-font-size); + font-family: var(--font-ui); + text-align: start; + color: var(--text-secondary); + transition: background 0.15s, color 0.15s; +} + +.noneOption:hover { + background: rgba(var(--primary-rgb) / 0.08); + color: var(--text-primary); +} + +.noneOptionSelected { + background: rgba(var(--primary-rgb) / 0.12); + color: var(--primary); + font-weight: 500; +} + +.row { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 6px 8px; + border-radius: 8px; + text-align: start; + color: var(--text-primary); + transition: background 0.15s; +} + +.row:hover { + background: rgba(var(--primary-rgb) / 0.08); +} + +.rowSelected { + background: rgba(var(--primary-rgb) / 0.12); + color: var(--primary); +} + +.rowSelected:hover { + background: rgba(var(--primary-rgb) / 0.16); +} + +.toggleBtn { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--text-muted); + background: transparent; + cursor: pointer; + outline: none; + transition: background 0.15s, color 0.15s; +} + +.toggleBtn:hover { + background: rgba(var(--primary-rgb) / 0.1); + color: var(--primary); +} + +.toggleSpacer { + flex-shrink: 0; + width: 24px; + height: 24px; +} + +.selectBtn { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; + padding: 2px 0; + text-align: start; + color: inherit; + font-size: var(--field-font-size); + font-family: var(--font-ui); + background: transparent; + cursor: pointer; + outline: none; +} + +.name { + font-weight: 500; +} + +.nameSecondary { + font-size: 12px; + font-weight: 400; + color: var(--text-muted); +} + +.rowSelected .nameSecondary { + color: inherit; + opacity: 0.75; +} + +.noResults { + padding: 16px 12px; + font-size: 13px; + color: var(--text-muted); + text-align: center; +} diff --git a/apps/customer/src/components/CategoryTreeSelect.tsx b/apps/customer/src/components/CategoryTreeSelect.tsx new file mode 100644 index 0000000..46fd975 --- /dev/null +++ b/apps/customer/src/components/CategoryTreeSelect.tsx @@ -0,0 +1,357 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown, Minus, Plus, Search, X } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import { useT } from '../i18n/useT' +import { Tooltip } from './Tooltip' +import treeStyles from './CategoryTree.module.css' +import styles from './CategoryTreeSelect.module.css' + +/** Minimal category shape for the shared tree picker. */ +export interface CategoryTreeItem { + id: string + parentId: string | null + nameEn: string + nameFa: string +} + +interface CategoryTreeSelectProps { + categories: CategoryTreeItem[] + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + id?: string + /** Show empty option + clear control. */ + allowEmpty?: boolean + /** Label for the empty option when `allowEmpty` is true. */ + emptyLabel?: string + /** Overlay floats over content; inFlow expands the parent. */ + dropdownVariant?: 'overlay' | 'inFlow' +} + +function getChildren(categories: CategoryTreeItem[], parentId: string | null) { + return categories + .filter((category) => category.parentId === parentId) + .sort((a, b) => a.nameEn.localeCompare(b.nameEn)) +} + +function getAncestorIds(categories: CategoryTreeItem[], id: string) { + const byId = new Map(categories.map((category) => [category.id, category])) + const ids: string[] = [] + let current = byId.get(id) + + while (current?.parentId) { + ids.push(current.parentId) + current = byId.get(current.parentId) + } + + return ids +} + +function categoryLabel(category: CategoryTreeItem, isFa: boolean) { + if (isFa) { + return category.nameFa + ? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}` + : category.nameEn + } + return category.nameEn + ? `${category.nameEn}${category.nameFa ? ` / ${category.nameFa}` : ''}` + : category.nameFa +} + +function categoryMatches(category: CategoryTreeItem, query: string) { + const q = query.trim().toLowerCase() + if (!q) return true + return ( + category.nameEn.toLowerCase().includes(q) || + category.nameFa.toLowerCase().includes(q) || + category.nameFa.includes(query.trim()) + ) +} + +function visibleIdsForQuery( + categories: CategoryTreeItem[], + query: string, +): Set | null { + const q = query.trim() + if (!q) return null + + const matched = categories.filter((category) => categoryMatches(category, q)) + const visible = new Set() + + for (const category of matched) { + visible.add(category.id) + for (const ancestorId of getAncestorIds(categories, category.id)) { + visible.add(ancestorId) + } + } + + return visible +} + +function TreeNodes({ + categories, + parentId, + value, + expandedIds, + visibleIds, + forceExpanded, + onToggle, + onSelect, +}: { + categories: CategoryTreeItem[] + parentId: string | null + value: string + expandedIds: Set + visibleIds: Set | null + forceExpanded: boolean + onToggle: (id: string) => void + onSelect: (id: string) => void +}) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const nodes = getChildren(categories, parentId).filter( + (category) => !visibleIds || visibleIds.has(category.id), + ) + + return ( + <> + {nodes.map((category) => { + const childCount = getChildren(categories, category.id).some( + (child) => !visibleIds || visibleIds.has(child.id), + ) + const expanded = forceExpanded || expandedIds.has(category.id) + const selected = value === category.id + const primary = isFa ? category.nameFa || category.nameEn : category.nameEn + const secondary = isFa + ? category.nameFa && category.nameEn && category.nameFa !== category.nameEn + ? category.nameEn + : '' + : category.nameFa + + return ( +
+
+ {childCount ? ( + + + + ) : ( + + )} + +
+ {childCount ? ( +
+
+ +
+
+ ) : null} +
+ ) + })} + + ) +} + +export function CategoryTreeSelect({ + categories, + value, + onChange, + placeholder, + disabled = false, + id, + allowEmpty = false, + emptyLabel, + dropdownVariant = 'overlay', +}: CategoryTreeSelectProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [expandedIds, setExpandedIds] = useState>(() => new Set()) + const containerRef = useRef(null) + + const selected = categories.find((category) => category.id === value) + const selectedLabel = selected ? categoryLabel(selected, isFa) : '' + const searchPlaceholder = placeholder ?? t('categories.select.search') + const emptyOptionLabel = emptyLabel ?? t('categories.select.none') + + const visibleIds = useMemo( + () => visibleIdsForQuery(categories, query), + [categories, query], + ) + + useEffect(() => { + setOpen(false) + setQuery('') + setExpandedIds(new Set()) + }, [categories]) + + useEffect(() => { + if (!open) return + function onClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + setQuery('') + } + } + document.addEventListener('mousedown', onClickOutside) + return () => document.removeEventListener('mousedown', onClickOutside) + }, [open]) + + useEffect(() => { + if (!value) return + setExpandedIds((prev) => { + const next = new Set(prev) + for (const ancestorId of getAncestorIds(categories, value)) { + next.add(ancestorId) + } + return next + }) + }, [categories, value]) + + function handleToggle(toggleId: string) { + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(toggleId)) next.delete(toggleId) + else next.add(toggleId) + return next + }) + } + + function selectOption(nextId: string) { + onChange(nextId) + setOpen(false) + setQuery('') + } + + const searching = query.trim().length > 0 + const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0 + + return ( +
+
+ + { + setQuery(e.target.value) + if (!open) setOpen(true) + }} + onClick={() => { + if (!disabled) setOpen(true) + }} + autoComplete="off" + role="combobox" + aria-expanded={open} + aria-autocomplete="list" + /> + {allowEmpty && value && !open && !disabled ? ( + + ) : null} + +
+ + {open && !disabled ? ( +
+ {allowEmpty ? ( + + ) : null} + {!hasVisible ? ( +

{t('categories.select.empty')}

+ ) : ( + + )} +
+ ) : null} +
+ ) +} diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index 01e3875..104c690 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -396,6 +396,13 @@ const en = { 'otp.signingIn': 'Signing in...', 'otp.error.signIn': 'Unable to sign in with SMS code.', + 'categories.tooltip.expand': 'Expand', + 'categories.tooltip.collapse': 'Collapse', + 'categories.select.search': 'Search categories…', + 'categories.select.none': '— No category —', + 'categories.select.empty': 'No categories found', + 'categories.select.clear': 'Clear selection', + 'common.close': 'Close', 'common.resendIn': 'Resend code in {seconds}s', 'common.resend': 'Resend SMS code', @@ -800,6 +807,13 @@ const fa: Record = { 'otp.signingIn': 'در حال ورود...', 'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.', + 'categories.tooltip.expand': 'باز کردن', + 'categories.tooltip.collapse': 'بستن', + 'categories.select.search': 'جستجوی دسته‌بندی‌ها…', + 'categories.select.none': '— بدون دسته‌بندی —', + 'categories.select.empty': 'دسته‌بندی‌ای یافت نشد', + 'categories.select.clear': 'پاک کردن انتخاب', + 'common.close': 'بستن', 'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه', 'common.resend': 'ارسال مجدد کد پیامکی', diff --git a/apps/super-admin/src/components/InvoiceDraftFields.tsx b/apps/super-admin/src/components/InvoiceDraftFields.tsx index 7082efe..24c7476 100644 --- a/apps/super-admin/src/components/InvoiceDraftFields.tsx +++ b/apps/super-admin/src/components/InvoiceDraftFields.tsx @@ -1,12 +1,13 @@ import { Pencil, Plus, X } from 'lucide-react' import type { InvoiceItemTemplate } from '../types/invoice' import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice' -import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft' +import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft' import { draftItemFromItemTemplate, emptyDraftAccount, emptyDraftItem, emptyDraftKeyPoint, + emptyDraftPaymentMethod, } from '../utils/invoiceDraft' import tableStyles from '../pages/BusinessesPage.module.css' import styles from '../pages/BusinessInvoicesPage.module.css' @@ -17,15 +18,23 @@ function focusKeyPointInput(index: number) { el?.select() } +function focusPaymentMethodInput(index: number) { + const el = document.querySelector(`input[data-payment-method-index="${index}"]`) + el?.focus() + el?.select() +} + type Props = { itemTemplates: InvoiceItemTemplate[] items: DraftLineItem[] keyPoints: DraftKeyPoint[] + paymentMethods: DraftPaymentMethod[] accounts: DraftAccount[] selectedItemTemplateId: string onSelectedItemTemplateId: (id: string) => void onItemsChange: (items: DraftLineItem[]) => void onKeyPointsChange: (points: DraftKeyPoint[]) => void + onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void onAccountsChange: (accounts: DraftAccount[]) => void showItemTemplatePicker?: boolean } @@ -34,11 +43,13 @@ export function InvoiceDraftFields({ itemTemplates, items, keyPoints, + paymentMethods, accounts, selectedItemTemplateId, onSelectedItemTemplateId, onItemsChange, onKeyPointsChange, + onPaymentMethodsChange, onAccountsChange, showItemTemplatePicker = true, }: Props) { @@ -239,6 +250,67 @@ export function InvoiceDraftFields({ )}
+
+
+

Payment methods

+ +
+ {paymentMethods.length === 0 ? ( +

No payment methods yet.

+ ) : ( +
+ {paymentMethods.map((method, index) => ( +
+
+ + 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}`} + /> +
+ +
+ ))} +
+ )} +
+

Account numbers

diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx index 0ae81ce..ec45d55 100644 --- a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx +++ b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx @@ -429,6 +429,17 @@ export function BusinessInvoicesPage() {
) : null} + {(detailInvoice.paymentMethods?.length ?? 0) > 0 ? ( +
+

Payment methods

+
    + {detailInvoice.paymentMethods!.map((pm) => ( +
  • {pm.text}
  • + ))} +
+
+ ) : null} + {(detailInvoice.accounts?.length ?? 0) > 0 ? (

Accounts

diff --git a/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx b/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx index 31a5a05..c54cea6 100644 --- a/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx +++ b/apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx @@ -15,12 +15,14 @@ import type { InvoiceItemTemplate } from '../types/invoice' import { buildAccountsPayload, buildKeyPointsPayload, + buildPaymentMethodsPayload, buildTemplateItemsPayload, draftsFromInvoiceTemplate, emptyDraftItem, type DraftAccount, type DraftKeyPoint, type DraftLineItem, + type DraftPaymentMethod, } from '../utils/invoiceDraft' import pageStyles from '../components/PageContent.module.css' import tableStyles from './BusinessesPage.module.css' @@ -40,6 +42,7 @@ export function InvoiceTemplateEditorPage() { const [topText, setTopText] = useState('') const [items, setItems] = useState([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) + const [paymentMethods, setPaymentMethods] = useState([]) const [accounts, setAccounts] = useState([]) const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') const [submitting, setSubmitting] = useState(false) @@ -62,6 +65,7 @@ export function InvoiceTemplateEditorPage() { setTopText(drafts.topText) setItems(drafts.items) setKeyPoints(drafts.keyPoints) + setPaymentMethods(drafts.paymentMethods) setAccounts(drafts.accounts) } } catch (err) { @@ -95,6 +99,7 @@ export function InvoiceTemplateEditorPage() { topText: isEmptyRichText(topText) ? undefined : topText, items: lineItems, keyPoints: buildKeyPointsPayload(keyPoints), + paymentMethods: buildPaymentMethodsPayload(paymentMethods), accounts: buildAccountsPayload(accounts), } @@ -127,7 +132,7 @@ export function InvoiceTemplateEditorPage() { {isEdit ? 'Edit invoice template' : 'Add invoice template'}

- Define name, top text, line items, key points, and bank accounts for reuse when issuing + Define name, top text, line items, key points, payment methods, and bank accounts for reuse when issuing invoices.

@@ -164,11 +169,13 @@ export function InvoiceTemplateEditorPage() { itemTemplates={itemTemplates} items={items} keyPoints={keyPoints} + paymentMethods={paymentMethods} accounts={accounts} selectedItemTemplateId={selectedItemTemplateId} onSelectedItemTemplateId={setSelectedItemTemplateId} onItemsChange={setItems} onKeyPointsChange={setKeyPoints} + onPaymentMethodsChange={setPaymentMethods} onAccountsChange={setAccounts} /> diff --git a/apps/super-admin/src/pages/IssueInvoicePage.tsx b/apps/super-admin/src/pages/IssueInvoicePage.tsx index e4245ab..55145d3 100644 --- a/apps/super-admin/src/pages/IssueInvoicePage.tsx +++ b/apps/super-admin/src/pages/IssueInvoicePage.tsx @@ -18,12 +18,14 @@ import { buildAccountsPayload, buildKeyPointsPayload, buildLineItemsPayload, + buildPaymentMethodsPayload, draftsFromInvoice, draftsFromInvoiceTemplate, emptyDraftItem, type DraftAccount, type DraftKeyPoint, type DraftLineItem, + type DraftPaymentMethod, } from '../utils/invoiceDraft' import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice' import pageStyles from '../components/PageContent.module.css' @@ -58,6 +60,7 @@ export function IssueInvoicePage() { const [notes, setNotes] = useState('') const [draftItems, setDraftItems] = useState([emptyDraftItem()]) const [keyPoints, setKeyPoints] = useState([]) + const [paymentMethods, setPaymentMethods] = useState([]) const [accounts, setAccounts] = useState([]) const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('') const [submitting, setSubmitting] = useState(false) @@ -107,6 +110,7 @@ export function IssueInvoicePage() { setNotes(drafts.notes) setDraftItems(drafts.items) setKeyPoints(drafts.keyPoints) + setPaymentMethods(drafts.paymentMethods) setAccounts(drafts.accounts) setSelectedItemTemplateId('') } @@ -130,6 +134,7 @@ export function IssueInvoicePage() { setNotes('') setDraftItems([emptyDraftItem()]) setKeyPoints([]) + setPaymentMethods([]) setAccounts([]) setSelectedItemTemplateId('') setFormError('') @@ -150,6 +155,7 @@ export function IssueInvoicePage() { setTopText(drafts.topText) setDraftItems(drafts.items) setKeyPoints(drafts.keyPoints) + setPaymentMethods(drafts.paymentMethods) setAccounts(drafts.accounts) setSelectedItemTemplateId('') setFormError('') @@ -176,6 +182,7 @@ export function IssueInvoicePage() { notes: notes.trim() || null, invoiceTemplateId: invoiceTemplateId ?? null, keyPoints: buildKeyPointsPayload(keyPoints), + paymentMethods: buildPaymentMethodsPayload(paymentMethods), accounts: buildAccountsPayload(accounts), }) showToast('Invoice updated.', 'success') @@ -187,6 +194,7 @@ export function IssueInvoicePage() { notes: notes.trim() || undefined, invoiceTemplateId, keyPoints: buildKeyPointsPayload(keyPoints), + paymentMethods: buildPaymentMethodsPayload(paymentMethods), accounts: buildAccountsPayload(accounts), }) showToast('Invoice issued.', 'success') @@ -300,11 +308,13 @@ export function IssueInvoicePage() { itemTemplates={itemTemplates} items={draftItems} keyPoints={keyPoints} + paymentMethods={paymentMethods} accounts={accounts} selectedItemTemplateId={selectedItemTemplateId} onSelectedItemTemplateId={setSelectedItemTemplateId} onItemsChange={setDraftItems} onKeyPointsChange={setKeyPoints} + onPaymentMethodsChange={setPaymentMethods} onAccountsChange={setAccounts} /> diff --git a/apps/super-admin/src/pages/PublicInvoicePage.tsx b/apps/super-admin/src/pages/PublicInvoicePage.tsx index b9b669f..e5f416a 100644 --- a/apps/super-admin/src/pages/PublicInvoicePage.tsx +++ b/apps/super-admin/src/pages/PublicInvoicePage.tsx @@ -23,6 +23,7 @@ const COPY: Record< subtotal: string total: string keyPoints: string + paymentMethods: string accounts: string card: string iban: string @@ -45,6 +46,7 @@ const COPY: Record< subtotal: 'Subtotal', total: 'Total', keyPoints: 'Key points', + paymentMethods: 'Payment methods', accounts: 'Account numbers', card: 'Card', iban: 'IBAN', @@ -66,6 +68,7 @@ const COPY: Record< subtotal: 'جمع جزء', total: 'مبلغ کل', keyPoints: 'نکات کلیدی', + paymentMethods: 'نحوه ی پرداخت', accounts: 'شماره حساب‌ها', card: 'کارت', iban: 'شبا', @@ -240,6 +243,17 @@ export function PublicInvoicePage() { ) : null} + {(invoice.paymentMethods?.length ?? 0) > 0 ? ( +
+

{t.paymentMethods}

+
    + {invoice.paymentMethods!.map((pm) => ( +
  • {pm.text}
  • + ))} +
+
+ ) : null} + {(invoice.accounts?.length ?? 0) > 0 ? (

{t.accounts}

diff --git a/apps/super-admin/src/types/invoice.ts b/apps/super-admin/src/types/invoice.ts index fc44795..c727ed6 100644 --- a/apps/super-admin/src/types/invoice.ts +++ b/apps/super-admin/src/types/invoice.ts @@ -35,6 +35,12 @@ export interface InvoiceKeyPoint { sortOrder: number } +export interface InvoicePaymentMethod { + id: string + text: string + sortOrder: number +} + export interface InvoiceAccount { id: string bankName: string @@ -79,6 +85,7 @@ export interface Invoice { } | null items?: InvoiceItem[] keyPoints?: InvoiceKeyPoint[] + paymentMethods?: InvoicePaymentMethod[] accounts?: InvoiceAccount[] subtotal?: number total?: number @@ -102,6 +109,7 @@ export type PublicInvoice = { } items?: InvoiceItem[] keyPoints?: InvoiceKeyPoint[] + paymentMethods?: InvoicePaymentMethod[] accounts?: InvoiceAccount[] subtotal?: number total?: number @@ -122,6 +130,10 @@ export interface InvoiceKeyPointInput { text: string } +export interface InvoicePaymentMethodInput { + text: string +} + export interface InvoiceAccountInput { bankName: string accountHolderName?: string @@ -137,6 +149,7 @@ export interface CreateInvoicePayload { notes?: string invoiceTemplateId?: string keyPoints?: InvoiceKeyPointInput[] + paymentMethods?: InvoicePaymentMethodInput[] accounts?: InvoiceAccountInput[] status?: InvoiceStatus } @@ -149,6 +162,7 @@ export type UpdateInvoicePayload = { notes?: string | null invoiceTemplateId?: string | null keyPoints?: InvoiceKeyPointInput[] + paymentMethods?: InvoicePaymentMethodInput[] accounts?: InvoiceAccountInput[] } @@ -176,6 +190,7 @@ export interface InvoiceTemplate { updatedAt: string items: InvoiceTemplateItem[] keyPoints: InvoiceKeyPoint[] + paymentMethods: InvoicePaymentMethod[] accounts: InvoiceAccount[] } @@ -194,6 +209,7 @@ export interface CreateInvoiceTemplatePayload { topText?: string items: InvoiceTemplateItemInput[] keyPoints?: InvoiceKeyPointInput[] + paymentMethods?: InvoicePaymentMethodInput[] accounts?: InvoiceAccountInput[] sortOrder?: number } diff --git a/apps/super-admin/src/utils/invoiceDraft.ts b/apps/super-admin/src/utils/invoiceDraft.ts index 5ac6875..17191f1 100644 --- a/apps/super-admin/src/utils/invoiceDraft.ts +++ b/apps/super-admin/src/utils/invoiceDraft.ts @@ -4,6 +4,7 @@ import type { InvoiceItemInput, InvoiceItemTemplate, InvoiceKeyPointInput, + InvoicePaymentMethodInput, InvoiceTemplate, InvoiceTemplateItemInput, } from '../types/invoice' @@ -21,6 +22,8 @@ export type DraftLineItem = { export type DraftKeyPoint = { key: string; text: string } +export type DraftPaymentMethod = { key: string; text: string } + export type DraftAccount = { key: string bankName: string @@ -49,6 +52,10 @@ export function emptyDraftKeyPoint(): DraftKeyPoint { return { key: newKey(), text: '' } } +export function emptyDraftPaymentMethod(): DraftPaymentMethod { + return { key: newKey(), text: '' } +} + export function emptyDraftAccount(): DraftAccount { return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' } } @@ -74,6 +81,7 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): { topText: string items: DraftLineItem[] keyPoints: DraftKeyPoint[] + paymentMethods: DraftPaymentMethod[] accounts: DraftAccount[] } { return { @@ -99,6 +107,10 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): { template.keyPoints.length > 0 ? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text })) : [], + paymentMethods: + (template.paymentMethods?.length ?? 0) > 0 + ? template.paymentMethods.map((pm) => ({ key: newKey(), text: pm.text })) + : [], accounts: template.accounts.length > 0 ? template.accounts.map((acc) => ({ @@ -126,6 +138,7 @@ export function draftsFromInvoice(invoice: { discountedPrice: number | null }> keyPoints?: Array<{ text: string }> + paymentMethods?: Array<{ text: string }> accounts?: Array<{ bankName: string accountHolderName: string | null @@ -138,6 +151,7 @@ export function draftsFromInvoice(invoice: { notes: string items: DraftLineItem[] keyPoints: DraftKeyPoint[] + paymentMethods: DraftPaymentMethod[] accounts: DraftAccount[] } { return { @@ -164,6 +178,10 @@ export function draftsFromInvoice(invoice: { (invoice.keyPoints?.length ?? 0) > 0 ? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text })) : [], + paymentMethods: + (invoice.paymentMethods?.length ?? 0) > 0 + ? invoice.paymentMethods!.map((pm) => ({ key: newKey(), text: pm.text })) + : [], accounts: (invoice.accounts?.length ?? 0) > 0 ? invoice.accounts!.map((acc) => ({ @@ -223,6 +241,15 @@ export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointI .map((text) => ({ text })) } +export function buildPaymentMethodsPayload( + methods: DraftPaymentMethod[], +): InvoicePaymentMethodInput[] { + return methods + .map((m) => m.text.trim()) + .filter(Boolean) + .map((text) => ({ text })) +} + export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] { return accounts .map((acc) => ({ diff --git a/packages/dashboard-core/src/utils/documentTitle.ts b/packages/dashboard-core/src/utils/documentTitle.ts index c7180ec..a2f1ee7 100644 --- a/packages/dashboard-core/src/utils/documentTitle.ts +++ b/packages/dashboard-core/src/utils/documentTitle.ts @@ -4,7 +4,7 @@ export interface DashboardDocumentTitleParts { pageLabels?: string[] } -/** Pattern: `{businessName} - {dashboardName} · {page}` */ +/** Pattern: `{dashboardName} · {page} - {businessName}` */ export function formatDashboardDocumentTitle({ businessName, dashboardName, @@ -13,12 +13,10 @@ export function formatDashboardDocumentTitle({ const business = businessName.trim() || 'Store' const dashboard = dashboardName.trim() const pages = pageLabels.map((label) => label.trim()).filter(Boolean) + const pagePart = + pages.length === 0 ? dashboard : `${dashboard} · ${pages.join(' · ')}` - if (pages.length === 0) { - return `${business} - ${dashboard}` - } - - return `${business} - ${dashboard} · ${pages.join(' · ')}` + return `${pagePart} - ${business}` } export interface RouteTitleRule {