mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-12 06:40:57 +04:30
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
|
|
import { ProductCard } from '../components/ProductCard'
|
|
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
|
import { Pagination } from '../components/Pagination'
|
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|
import { ProductVariantsModal } from '../components/ProductVariantsModal'
|
|
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
|
|
import { ProductCommentsModal } from '../components/ProductCommentsModal'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError } from '../lib/api'
|
|
import {
|
|
deleteProduct,
|
|
listProducts,
|
|
PRODUCTS_PER_PAGE,
|
|
} from '../services/productService'
|
|
import type { Product } from '../types/product'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import filterStyles from '../components/ListFiltersPanel.module.css'
|
|
import styles from './MyProductsPage.module.css'
|
|
import aiStyles from '../styles/ai.module.css'
|
|
|
|
function readListQuery(searchParams: URLSearchParams) {
|
|
const name = searchParams.get('q')?.trim() ?? ''
|
|
const pageRaw = Number(searchParams.get('page'))
|
|
const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1
|
|
return { name, page }
|
|
}
|
|
|
|
export function MyProductsPage() {
|
|
const t = useT()
|
|
const navigate = useNavigate()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const { name: appliedName, page: currentPage } = readListQuery(searchParams)
|
|
|
|
const [products, setProducts] = useState<Product[]>([])
|
|
const [totalProducts, setTotalProducts] = useState(0)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [variantCounts, setVariantCounts] = useState<Record<string, number>>({})
|
|
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
|
|
const [variantsTarget, setVariantsTarget] = useState<{
|
|
id: string
|
|
name: string
|
|
categoryId: string
|
|
} | null>(null)
|
|
const [commentsTarget, setCommentsTarget] = useState<{ id: string; name: string } | null>(null)
|
|
const [technicalTarget, setTechnicalTarget] = useState<{
|
|
id: string
|
|
name: string
|
|
categoryId: string
|
|
} | null>(null)
|
|
const [aiModalOpen, setAiModalOpen] = useState(false)
|
|
const [draftName, setDraftName] = useState(appliedName)
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
|
|
|
|
useEffect(() => {
|
|
setDraftName(appliedName)
|
|
}, [appliedName])
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
void loadProducts(currentPage, controller.signal)
|
|
return () => controller.abort()
|
|
}, [currentPage, appliedName])
|
|
|
|
function writeListQuery(name: string, page: number) {
|
|
const next = new URLSearchParams()
|
|
if (name) next.set('q', name)
|
|
if (page > 1) next.set('page', String(page))
|
|
setSearchParams(next, { replace: true })
|
|
}
|
|
|
|
async function loadProducts(page: number, signal?: AbortSignal) {
|
|
setIsLoading(true)
|
|
setError('')
|
|
try {
|
|
const data = await listProducts(
|
|
page,
|
|
PRODUCTS_PER_PAGE,
|
|
signal,
|
|
appliedName ? { name: appliedName } : undefined,
|
|
)
|
|
setProducts(data.items)
|
|
setTotalProducts(data.total)
|
|
setVariantCounts(
|
|
Object.fromEntries(data.items.map((product) => [product.id, product.variantCount ?? 0])),
|
|
)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('products.list.errorLoad'))
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
function handleEdit(id: string) {
|
|
navigate(`/products/edit/${id}`)
|
|
}
|
|
|
|
function handleComments(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setCommentsTarget({ id, name: product.nameFa || product.nameEn })
|
|
}
|
|
}
|
|
|
|
function handleVariations(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setVariantsTarget({
|
|
id,
|
|
name: product.nameFa || product.nameEn,
|
|
categoryId: product.categoryId,
|
|
})
|
|
}
|
|
}
|
|
|
|
function handleTechnicalInfo(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setTechnicalTarget({
|
|
id,
|
|
name: product.nameFa || product.nameEn,
|
|
categoryId: product.categoryId,
|
|
})
|
|
}
|
|
}
|
|
|
|
function handleRemoveRequest(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setDeleteTarget({ id, name: product.nameFa || product.nameEn })
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleteTarget) return
|
|
|
|
setIsDeleting(true)
|
|
setError('')
|
|
|
|
try {
|
|
await deleteProduct(deleteTarget.id)
|
|
const nextTotal = totalProducts - 1
|
|
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PRODUCTS_PER_PAGE))
|
|
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
|
setDeleteTarget(null)
|
|
writeListQuery(appliedName, nextPage)
|
|
await loadProducts(nextPage)
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('products.list.errorDelete'))
|
|
}
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
function handlePageChange(page: number) {
|
|
writeListQuery(appliedName, page)
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
}
|
|
|
|
function applyFilters() {
|
|
writeListQuery(draftName.trim(), 1)
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftName('')
|
|
writeListQuery('', 1)
|
|
}
|
|
|
|
function handleCommentCountChange(count: number) {
|
|
if (!commentsTarget) return
|
|
setCommentCounts((prev) => ({
|
|
...prev,
|
|
[commentsTarget.id]: count,
|
|
}))
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Products', href: '/products' },
|
|
{ label: 'My Products' },
|
|
]}
|
|
/>
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<h2 className={pageStyles.pageTitle}>{t('title.myProducts')}</h2>
|
|
<p className={pageStyles.pageSubtitle}>
|
|
{t('products.list.subtitle', { count: totalProducts })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={filterStyles.filtersPanel}>
|
|
<div className={filterStyles.filtersTitle}>{t('products.list.filters')}</div>
|
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
|
<div className={filterStyles.filtersInputs}>
|
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
|
|
<input
|
|
id="filter-product-name"
|
|
value={draftName}
|
|
onChange={(e) => setDraftName(e.target.value)}
|
|
placeholder={t('products.list.filterNamePlaceholder')}
|
|
aria-label={t('products.list.filterName')}
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={filterStyles.filterActions}>
|
|
<button
|
|
type="submit"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
|
disabled={isLoading}
|
|
aria-label={t('products.list.search')}
|
|
title={t('products.list.search')}
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={isLoading}
|
|
aria-label={t('products.list.clearFilters')}
|
|
title={t('products.list.clearFilters')}
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className={styles.error} role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<p className={styles.empty}>{t('products.list.loading')}</p>
|
|
) : products.length === 0 ? (
|
|
<p className={styles.empty}>
|
|
{appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className={styles.grid}>
|
|
{products.map((product) => (
|
|
<ProductCard
|
|
key={product.id}
|
|
product={product}
|
|
commentCount={commentCounts[product.id] ?? product.commentCount}
|
|
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
|
|
onEdit={handleEdit}
|
|
onQuickInfo={() => {}}
|
|
onComments={handleComments}
|
|
onTechnicalInfo={handleTechnicalInfo}
|
|
onVariations={handleVariations}
|
|
onRemove={handleRemoveRequest}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className={styles.pagination}>
|
|
<div className={styles.paginationMeta}>
|
|
{t('products.list.pagination', {
|
|
page: currentPage,
|
|
totalPages,
|
|
perPage: PRODUCTS_PER_PAGE,
|
|
total: totalProducts,
|
|
})}
|
|
</div>
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={handlePageChange}
|
|
disabled={isLoading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<ConfirmDeleteModal
|
|
open={!!deleteTarget}
|
|
title={t('products.list.deleteTitle')}
|
|
message={
|
|
deleteTarget
|
|
? t('products.list.deleteMessage', { name: deleteTarget.name })
|
|
: ''
|
|
}
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => !isDeleting && setDeleteTarget(null)}
|
|
/>
|
|
|
|
{variantsTarget && (
|
|
<ProductVariantsModal
|
|
open={!!variantsTarget}
|
|
productId={variantsTarget.id}
|
|
categoryId={variantsTarget.categoryId}
|
|
productName={variantsTarget.name}
|
|
onClose={() => setVariantsTarget(null)}
|
|
onVariantsChange={(count) => {
|
|
setVariantCounts((prev) => ({
|
|
...prev,
|
|
[variantsTarget.id]: count,
|
|
}))
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{commentsTarget && (
|
|
<ProductCommentsModal
|
|
open={!!commentsTarget}
|
|
productId={commentsTarget.id}
|
|
productName={commentsTarget.name}
|
|
onClose={() => setCommentsTarget(null)}
|
|
onCountChange={handleCommentCountChange}
|
|
/>
|
|
)}
|
|
|
|
{technicalTarget && (
|
|
<ProductTechnicalInfoModal
|
|
open={!!technicalTarget}
|
|
productId={technicalTarget.id}
|
|
productName={technicalTarget.name}
|
|
categoryId={technicalTarget.categoryId}
|
|
onClose={() => setTechnicalTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
<AddProductByAiModal
|
|
open={aiModalOpen}
|
|
onClose={() => setAiModalOpen(false)}
|
|
onCreated={(productId) => navigate(`/products/edit/${productId}`)}
|
|
/>
|
|
|
|
<div className={styles.fabDock}>
|
|
<button
|
|
type="button"
|
|
className={aiStyles.aiFabStrip}
|
|
onClick={() => setAiModalOpen(true)}
|
|
>
|
|
<Sparkles size={18} />
|
|
<span>{t('products.list.addByAi')}</span>
|
|
</button>
|
|
<Link to="/products/new" className={styles.addFab} aria-label={t('products.list.addNew')}>
|
|
<Plus size={24} />
|
|
</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|