Add special orders admin UI and product specials tools.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b103e330ef
commit
d63e3a83d5
@@ -139,6 +139,97 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prepRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.prepInputWrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prepInputWrap input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prepUnit {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inputBlurred {
|
||||
filter: blur(2.5px);
|
||||
opacity: 0.55;
|
||||
transition:
|
||||
filter 0.2s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
|
||||
.prepSwitchWrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
height: var(--field-height);
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
|
||||
.prepSwitchText {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(143, 65, 12, 0.18);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.switch:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switchOn {
|
||||
background: var(--brown);
|
||||
}
|
||||
|
||||
.switchThumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
inset-inline-start: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fffafa;
|
||||
box-shadow: 0 1px 4px rgba(42, 24, 16, 0.2);
|
||||
transition: inset-inline-start 0.2s var(--ease-out);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.switchOn .switchThumb {
|
||||
inset-inline-start: 21px;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -53,6 +53,8 @@ export function AddProductPage() {
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||
const [nameFa, setNameFa] = useState('')
|
||||
const [nameEn, setNameEn] = useState('')
|
||||
const [readyImmediately, setReadyImmediately] = useState(true)
|
||||
const [preparationHours, setPreparationHours] = useState('')
|
||||
const [sellUnit, setSellUnit] = useState<SellUnit>('unit')
|
||||
const [price, setPrice] = useState('')
|
||||
const [intro, setIntro] = useState('')
|
||||
@@ -130,6 +132,13 @@ export function AddProductPage() {
|
||||
setCategoryId(product.categoryId)
|
||||
setSellUnit(product.sellUnit)
|
||||
setPrice(String(product.price))
|
||||
if (product.preparationHours != null && product.preparationHours > 0) {
|
||||
setReadyImmediately(false)
|
||||
setPreparationHours(String(product.preparationHours))
|
||||
} else {
|
||||
setReadyImmediately(true)
|
||||
setPreparationHours('')
|
||||
}
|
||||
setIntro(product.intro ?? '')
|
||||
setDescription(product.description ?? '')
|
||||
setTags(product.tags ?? [])
|
||||
@@ -213,6 +222,15 @@ export function AddProductPage() {
|
||||
setError('نام فارسی و انگلیسی الزامی است')
|
||||
return
|
||||
}
|
||||
let prepHours: number | null = null
|
||||
if (!readyImmediately) {
|
||||
const hours = Number(preparationHours)
|
||||
if (!Number.isInteger(hours) || hours < 1) {
|
||||
setError('زمان آمادهسازی باید حداقل ۱ ساعت باشد')
|
||||
return
|
||||
}
|
||||
prepHours = hours
|
||||
}
|
||||
const numericPrice = parsePriceNumber(price)
|
||||
if (numericPrice === null || numericPrice <= 0) {
|
||||
setError('قیمت معتبر الزامی است')
|
||||
@@ -232,6 +250,7 @@ export function AddProductPage() {
|
||||
categoryId,
|
||||
price: numericPrice,
|
||||
sellUnit,
|
||||
preparationHours: prepHours,
|
||||
intro: intro.trim(),
|
||||
description,
|
||||
tags,
|
||||
@@ -424,6 +443,52 @@ export function AddProductPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-prep-hours">زمان آمادهسازی</label>
|
||||
<div className={pageStyles.prepRow}>
|
||||
<div className={pageStyles.prepSwitchWrap}>
|
||||
<button
|
||||
type="button"
|
||||
id="product-ready-immediately"
|
||||
className={`${pageStyles.switch} ${readyImmediately ? pageStyles.switchOn : ''}`}
|
||||
role="switch"
|
||||
aria-checked={readyImmediately}
|
||||
aria-label="اماده ارسال"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setReadyImmediately((prev) => {
|
||||
if (prev) {
|
||||
if (!preparationHours) setPreparationHours('1')
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className={pageStyles.switchThumb} />
|
||||
</button>
|
||||
<span className={pageStyles.prepSwitchText}>
|
||||
اماده ارسال
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`${pageStyles.prepInputWrap} ${readyImmediately ? pageStyles.inputBlurred : ''}`}
|
||||
>
|
||||
<input
|
||||
id="product-prep-hours"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="۱"
|
||||
value={preparationHours}
|
||||
onChange={(e) => setPreparationHours(e.target.value)}
|
||||
disabled={saving || readyImmediately}
|
||||
/>
|
||||
<span className={pageStyles.prepUnit}>ساعت</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.priceRow}>
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-sell-unit">نوع قیمت</label>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 22px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
border: 1px solid rgba(143, 65, 12, 0.08);
|
||||
animation: fadeUp 0.5s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.tab,
|
||||
.tabActive {
|
||||
min-height: 44px;
|
||||
padding: 8px 10px;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tab {
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab:hover:not(:disabled) {
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--brown);
|
||||
background: rgba(255, 250, 250, 0.95);
|
||||
box-shadow: 0 4px 12px rgba(143, 65, 12, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tabs {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.tabs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tab,
|
||||
.tabActive {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { ProductCard } from '../components/ProductCard'
|
||||
import {
|
||||
PRODUCT_SPECIALS,
|
||||
type ProductSpecialTag,
|
||||
} from '../components/ProductSpecialsModal'
|
||||
import type { Product } from '../data/products'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import { listProducts, updateProduct } from '../lib/productsApi'
|
||||
import styles from './HomePage.module.css'
|
||||
import listStyles from './ProductsListPage.module.css'
|
||||
import pageStyles from './ProductSpecialsPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 48
|
||||
|
||||
function matchesSpecialTag(tags: string[] | undefined, tag: ProductSpecialTag) {
|
||||
return (tags ?? []).some((item) => item === tag || item.includes(tag))
|
||||
}
|
||||
|
||||
function removeSpecialTag(tags: string[] | undefined, tag: ProductSpecialTag) {
|
||||
return (tags ?? []).filter((item) => item !== tag && !item.includes(tag))
|
||||
}
|
||||
|
||||
export function ProductSpecialsPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<ProductSpecialTag>(
|
||||
PRODUCT_SPECIALS[0].tag,
|
||||
)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const activeSpecial =
|
||||
PRODUCT_SPECIALS.find((item) => item.tag === activeTab) ??
|
||||
PRODUCT_SPECIALS[0]
|
||||
|
||||
const handleAuthError = useCallback((err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const loadProducts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listProducts({
|
||||
tag: activeTab,
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setProducts(
|
||||
result.items
|
||||
.filter((item) => matchesSpecialTag(item.tags, activeTab))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: item.options ?? [],
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setProducts([])
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری محصولات ویژه ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeTab, handleAuthError])
|
||||
|
||||
useEffect(() => {
|
||||
void loadProducts()
|
||||
}, [loadProducts])
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(false)
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [location.key, products, activeTab])
|
||||
|
||||
function handleView(product: Product) {
|
||||
navigate(`/products/${product.id}`)
|
||||
}
|
||||
|
||||
function handleEdit(product: Product) {
|
||||
navigate(`/products/${product.id}/edit`)
|
||||
}
|
||||
|
||||
async function handleRemove(product: Product) {
|
||||
const confirmed = window.confirm(
|
||||
`محصول «${product.nameFa}» از «${activeSpecial.label}» حذف شود؟`,
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setBusyId(product.id)
|
||||
setError('')
|
||||
try {
|
||||
await updateProduct(product.id, {
|
||||
tags: removeSpecialTag(product.tags, activeTab),
|
||||
})
|
||||
setProducts((current) =>
|
||||
current.filter((item) => item.id !== product.id),
|
||||
)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'حذف از محصولات ویژه ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Special Products
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/products" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>محصولات ویژه</h1>
|
||||
<p className={styles.lead}>
|
||||
محصولات هر بخش ویژه را ببینید و در صورت نیاز حذف کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className={pageStyles.tabs}
|
||||
role="tablist"
|
||||
aria-label="بخشهای ویژه"
|
||||
>
|
||||
{PRODUCT_SPECIALS.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((special) => {
|
||||
const selected = special.tag === activeTab
|
||||
return (
|
||||
<button
|
||||
key={special.tag}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
className={selected ? pageStyles.tabActive : pageStyles.tab}
|
||||
onClick={() => setActiveTab(special.tag)}
|
||||
>
|
||||
{special.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={listStyles.empty} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className={listStyles.empty}>در حال بارگذاری...</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className={listStyles.empty}>
|
||||
محصولی در «{activeSpecial.label}» ثبت نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<section
|
||||
className={listStyles.grid}
|
||||
role="tabpanel"
|
||||
aria-label={activeSpecial.label}
|
||||
>
|
||||
{products.map((product, index) => (
|
||||
<ProductCard
|
||||
key={`${activeTab}-${product.id}`}
|
||||
product={product}
|
||||
index={index}
|
||||
visible={visible}
|
||||
busy={busyId === product.id}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onRemove={handleRemove}
|
||||
removeLabel="حذف از ویژه"
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { Header } from '../components/Header'
|
||||
import { ProductCard } from '../components/ProductCard'
|
||||
import { PriceInput } from '../components/PriceInput'
|
||||
import { ProductOptionsModal } from '../components/ProductOptionsModal'
|
||||
import {
|
||||
ProductSpecialsModal,
|
||||
type ProductSpecialTag,
|
||||
} from '../components/ProductSpecialsModal'
|
||||
import {
|
||||
flattenCategories,
|
||||
type Category,
|
||||
@@ -48,6 +52,7 @@ export function ProductsListPage() {
|
||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||
const [optionsProduct, setOptionsProduct] = useState<Product | null>(null)
|
||||
const [specialsProduct, setSpecialsProduct] = useState<Product | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
@@ -161,6 +166,7 @@ export function ProductsListPage() {
|
||||
current.filter((item) => item.id !== product.id),
|
||||
)
|
||||
if (optionsProduct?.id === product.id) setOptionsProduct(null)
|
||||
if (specialsProduct?.id === product.id) setSpecialsProduct(null)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
@@ -188,6 +194,34 @@ export function ProductsListPage() {
|
||||
setOptionsProduct(null)
|
||||
}
|
||||
|
||||
async function handleAddSpecial(
|
||||
productId: string,
|
||||
tag: ProductSpecialTag,
|
||||
) {
|
||||
const product =
|
||||
products.find((item) => item.id === productId) ?? specialsProduct
|
||||
if (!product) return
|
||||
|
||||
const existing = product.tags ?? []
|
||||
const alreadyHas = existing.some(
|
||||
(item) => item === tag || item.includes(tag),
|
||||
)
|
||||
const nextTags = alreadyHas ? existing : [...existing, tag]
|
||||
|
||||
if (!alreadyHas) {
|
||||
const updated = await updateProduct(productId, { tags: nextTags })
|
||||
setProducts((current) =>
|
||||
current.map((item) =>
|
||||
item.id === productId
|
||||
? { ...updated, options: updated.options ?? [] }
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
setSpecialsProduct(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
@@ -289,6 +323,7 @@ export function ProductsListPage() {
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onOpenOptions={setOptionsProduct}
|
||||
onMakeSpecial={setSpecialsProduct}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
@@ -316,6 +351,18 @@ export function ProductsListPage() {
|
||||
onClose={() => setOptionsProduct(null)}
|
||||
onSave={handleSaveOptions}
|
||||
/>
|
||||
|
||||
<ProductSpecialsModal
|
||||
open={Boolean(specialsProduct)}
|
||||
product={
|
||||
specialsProduct
|
||||
? (products.find((item) => item.id === specialsProduct.id) ??
|
||||
specialsProduct)
|
||||
: null
|
||||
}
|
||||
onClose={() => setSpecialsProduct(null)}
|
||||
onSelect={handleAddSpecial}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export function ProductsPage() {
|
||||
</p>
|
||||
<h1 className={styles.headline}>محصولات من</h1>
|
||||
<p className={styles.lead}>
|
||||
محصولات، ثبت محصول جدید و دستهبندیها را از اینجا مدیریت کنید.
|
||||
محصولات، ثبت محصول جدید، دستهبندیها و محصولات ویژه را از اینجا
|
||||
مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ProfilePage() {
|
||||
const [passwordOpen, setPasswordOpen] = useState(false)
|
||||
const [headerTick, setHeaderTick] = useState(0)
|
||||
|
||||
const [title, setTitle] = useState<UserTitle>(userTitles[0])
|
||||
const [title, setTitle] = useState<UserTitle>('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [category, setCategory] = useState<UserCategory | ''>('')
|
||||
@@ -50,7 +50,7 @@ export function ProfilePage() {
|
||||
const [roleLabel, setRoleLabel] = useState('')
|
||||
|
||||
function applyUser(user: User) {
|
||||
setTitle(user.title)
|
||||
setTitle(user.title || '')
|
||||
setFirstName(user.firstName)
|
||||
setLastName(user.lastName)
|
||||
setCategory(user.category ?? '')
|
||||
@@ -76,7 +76,7 @@ export function ProfilePage() {
|
||||
}
|
||||
const fallback = getSession()?.user
|
||||
if (fallback) {
|
||||
setTitle(fallback.title as UserTitle)
|
||||
setTitle((fallback.title as UserTitle) || '')
|
||||
setFirstName(fallback.firstName)
|
||||
setLastName(fallback.lastName)
|
||||
setCellNumber(fallback.cellNumber)
|
||||
@@ -123,7 +123,7 @@ export function ProfilePage() {
|
||||
lastName: updated.lastName,
|
||||
cellNumber: updated.cellNumber,
|
||||
role: updated.role,
|
||||
name: updated.name,
|
||||
name: `${updated.firstName} ${updated.lastName}`.trim(),
|
||||
})
|
||||
setHeaderTick((tick) => tick + 1)
|
||||
setSuccess('اطلاعات پروفایل ذخیره شد.')
|
||||
@@ -219,6 +219,7 @@ export function ProfilePage() {
|
||||
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">بدون عنوان</option>
|
||||
{userTitles.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
|
||||
@@ -79,8 +79,9 @@ export function SettingsPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const formId = useId()
|
||||
const [districtOptions, setDistrictOptions] =
|
||||
useState<string[]>(fallbackDistricts)
|
||||
const [districtOptions, setDistrictOptions] = useState<string[]>(() => [
|
||||
...fallbackDistricts,
|
||||
])
|
||||
const [exceptions, setExceptions] = useState<ShippingExceptionRow[]>([])
|
||||
const [branches, setBranches] = useState<BranchRow[]>([])
|
||||
const [initialBranchIds, setInitialBranchIds] = useState<string[]>([])
|
||||
@@ -112,7 +113,9 @@ export function SettingsPage() {
|
||||
listBranches(),
|
||||
])
|
||||
setDistrictOptions(
|
||||
districtList.length > 0 ? districtList : fallbackDistricts,
|
||||
districtList.length > 0
|
||||
? [...districtList]
|
||||
: [...fallbackDistricts],
|
||||
)
|
||||
setExceptions(
|
||||
shippingList.map((item) => ({
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.topBlock {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
height: var(--field-height);
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||
background: rgba(255, 250, 250, 0.85);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: var(--brown);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.filterBtn {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: var(--field-height);
|
||||
padding: 0 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterBtn:hover:not(:disabled) {
|
||||
background: var(--brown-dark);
|
||||
}
|
||||
|
||||
.listWrap {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(180px, 1.4fr)
|
||||
minmax(140px, 1.1fr)
|
||||
minmax(120px, 0.9fr)
|
||||
auto;
|
||||
gap: 10px 14px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 250, 250, 0.55);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(255, 250, 250, 0.9);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
border: 1px solid rgba(143, 65, 12, 0.16);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userMeta {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.phone {
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.metaSub {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--brown);
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.iconBtn.danger:hover {
|
||||
color: #9b2c2c;
|
||||
background: rgba(155, 44, 44, 0.08);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||
background: rgba(255, 250, 250, 0.75);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) {
|
||||
color: var(--brown);
|
||||
border-color: rgba(143, 65, 12, 0.28);
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageBtnActive {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
border-color: var(--brown);
|
||||
}
|
||||
|
||||
.pageBtnActive:hover:not(:disabled) {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown-dark);
|
||||
border-color: var(--brown-dark);
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
margin-inline-start: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
inset-inline-end: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 20;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--brown);
|
||||
color: var(--text-on-dark);
|
||||
box-shadow: 0 10px 28px rgba(143, 65, 12, 0.28);
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--brown-dark);
|
||||
}
|
||||
|
||||
.fab::after {
|
||||
content: 'ثبت سفارش ویژه';
|
||||
position: absolute;
|
||||
inset-inline-end: calc(100% + 10px);
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--brown-deeper);
|
||||
color: var(--text-on-dark);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.fab:hover::after {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, Eye, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { SpecialOrderDetailsModal } from '../components/SpecialOrderDetailsModal'
|
||||
import {
|
||||
SpecialOrderModal,
|
||||
type SpecialOrderFormValues,
|
||||
} from '../components/SpecialOrderModal'
|
||||
import {
|
||||
formatCellNumber,
|
||||
formatUserName,
|
||||
} from '../data/users'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import {
|
||||
createSpecialOrder,
|
||||
deleteSpecialOrder,
|
||||
listSpecialOrders,
|
||||
specialOrderCategoryLabel,
|
||||
updateSpecialOrder,
|
||||
type SpecialOrder,
|
||||
type SpecialOrderCategory,
|
||||
} from '../lib/specialOrdersApi'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './SpecialOrdersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
type Filters = {
|
||||
q: string
|
||||
category: '' | SpecialOrderCategory
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = {
|
||||
q: '',
|
||||
category: '',
|
||||
}
|
||||
|
||||
function formatCreatedAt(iso: string) {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function SpecialOrdersPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [items, setItems] = useState<SpecialOrder[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<SpecialOrder | null>(null)
|
||||
const [detailsOrder, setDetailsOrder] = useState<SpecialOrder | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listSpecialOrders({
|
||||
q: appliedFilters.q.trim() || undefined,
|
||||
category: appliedFilters.category || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setItems(result.items)
|
||||
setTotal(result.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری سفارشات ویژه ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedFilters, page, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
async function handleSubmit(values: SpecialOrderFormValues) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (editing) {
|
||||
await updateSpecialOrder(editing.id, values)
|
||||
} else {
|
||||
await createSpecialOrder(values)
|
||||
}
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
if (page !== 1 && !editing) {
|
||||
setPage(1)
|
||||
} else {
|
||||
await load()
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: SpecialOrder) {
|
||||
if (!window.confirm(`سفارش «${item.name}» حذف شود؟`)) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await deleteSpecialOrder(item.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'حذف سفارش ویژه ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.page}`}>
|
||||
<Header />
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.main}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Special Orders
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به صفحه اصلی
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>سفارشات ویژه</h1>
|
||||
<p className={styles.lead}>
|
||||
درخواستهای سفارشی کاربران بدون پرداخت و بدون لینک محصول — فقط برای
|
||||
پیگیری ادمین.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
className={pageStyles.filters}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setPage(1)
|
||||
setAppliedFilters({
|
||||
q: draftFilters.q.trim(),
|
||||
category: draftFilters.category,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label className={pageStyles.field}>
|
||||
<span>جستجو</span>
|
||||
<input
|
||||
value={draftFilters.q}
|
||||
onChange={(e) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
q: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="نام سفارش، کاربر یا موبایل"
|
||||
/>
|
||||
</label>
|
||||
<label className={pageStyles.field}>
|
||||
<span>دستهبندی</span>
|
||||
<select
|
||||
value={draftFilters.category}
|
||||
onChange={(e) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
category: e.target.value as Filters['category'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">همه</option>
|
||||
<option value="specialCake">کیک ویژه</option>
|
||||
<option value="specialPastry">شیرینی ویژه</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className={pageStyles.filterBtn} disabled={busy}>
|
||||
اعمال
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className={pageStyles.panel} role="alert">
|
||||
<p className={pageStyles.empty}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.listWrap}>
|
||||
{loading ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>سفارش ویژهای ثبت نشده است.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section
|
||||
className={pageStyles.panel}
|
||||
aria-label="فهرست سفارشات ویژه"
|
||||
>
|
||||
<ul className={pageStyles.list}>
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className={pageStyles.row}>
|
||||
<div className={pageStyles.identity}>
|
||||
<span className={pageStyles.name}>{item.name}</span>
|
||||
<span className={pageStyles.chip}>
|
||||
{specialOrderCategoryLabel[item.category]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`${pageStyles.meta} ${pageStyles.userMeta}`}>
|
||||
<span className={pageStyles.metaValue}>
|
||||
{formatUserName(item.user) || item.user.name}
|
||||
</span>
|
||||
<span className={pageStyles.phone} dir="ltr">
|
||||
{formatCellNumber(item.user.cellNumber)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.meta}>
|
||||
<span className={pageStyles.metaValue}>
|
||||
{formatCreatedAt(item.createdAt)}
|
||||
</span>
|
||||
<span className={pageStyles.metaSub}>
|
||||
{formatPrice(item.images.length)} تصویر
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="جزئیات"
|
||||
disabled={busy}
|
||||
onClick={() => setDetailsOrder(item)}
|
||||
>
|
||||
<Eye size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(item)
|
||||
setModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.iconBtn} ${pageStyles.danger}`}
|
||||
aria-label="حذف"
|
||||
disabled={busy}
|
||||
onClick={() => void handleDelete(item)}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && total > 0 && (
|
||||
<nav className={pageStyles.pagination} aria-label="صفحهبندی">
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page <= 1 || busy}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
|
||||
(pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
className={`${pageStyles.pageBtn} ${pageNumber === page ? pageStyles.pageBtnActive : ''}`}
|
||||
aria-current={pageNumber === page ? 'page' : undefined}
|
||||
disabled={busy}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{formatPrice(pageNumber)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page >= totalPages || busy}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
|
||||
<span className={pageStyles.pageInfo}>
|
||||
{formatPrice(total)} سفارش
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.fab}
|
||||
aria-label="ثبت سفارش ویژه"
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={22} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
<SpecialOrderModal
|
||||
open={modalOpen}
|
||||
editing={editing}
|
||||
onClose={() => {
|
||||
if (busy) return
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<SpecialOrderDetailsModal
|
||||
open={Boolean(detailsOrder)}
|
||||
order={detailsOrder}
|
||||
onClose={() => setDetailsOrder(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../lib/usersApi'
|
||||
import {
|
||||
formatCellNumber,
|
||||
formatUserName,
|
||||
isElevatedRole,
|
||||
userRoleLabel,
|
||||
userRoles,
|
||||
@@ -346,7 +347,7 @@ export function UsersPage() {
|
||||
>
|
||||
<div className={pageStyles.identity}>
|
||||
<span className={pageStyles.name}>
|
||||
{user.title} {user.firstName} {user.lastName}
|
||||
{formatUserName(user)}
|
||||
</span>
|
||||
<span className={pageStyles.cell} dir="ltr">
|
||||
{formatCellNumber(user.cellNumber)}
|
||||
@@ -520,9 +521,7 @@ export function UsersPage() {
|
||||
<ChangePasswordModal
|
||||
open={Boolean(passwordUser)}
|
||||
userName={
|
||||
passwordUser
|
||||
? `${passwordUser.title} ${passwordUser.firstName} ${passwordUser.lastName}`
|
||||
: ''
|
||||
passwordUser ? formatUserName(passwordUser) : ''
|
||||
}
|
||||
onClose={() => setPasswordUser(null)}
|
||||
onSubmit={(password) => void handlePasswordSubmit(password)}
|
||||
|
||||
Reference in New Issue
Block a user