fix some
This commit is contained in:
1 parent
be18543b04
commit
aa3872fbb1
20 files changed
+2079
-230
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# ripwire quality acks v1 — written by --quality-ack; a finding stays suppressed until it worsens past its acked magnitude
|
||||
# format: ack <kind> <16-hex-key> <ackNow> [cid=<16-hex-content-id>] [by=<scope that acked it>] <reason to end of line> — one per line, kept SORTED by (kind,key) on every write (merge-friendly)
|
||||
ack dead-code:new-symbol a3b73a9575dc4b25 0 cid=9f8bd7c52d3c614b Next file-system default route and JSX submit callback are runtime entrypoints; production build, served login HTML and component behavior tests verify reachability.
|
||||
ack dead-code:preexisting ebc196c3604f15d6 0 cid=969ff4264e9ceec8 Next file-system default route and JSX submit callback are runtime entrypoints; production build, served login HTML and component behavior tests verify reachability.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { logout } from '@/lib/auth'
|
||||
import axiosInstance from '@/axios/axiosInstance'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
@@ -8,7 +8,6 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
const NavTop = ()=>{
|
||||
|
||||
const router = useRouter();
|
||||
const [nameFamily, setNameFamily] = useState<string | null>(null)
|
||||
|
||||
useEffect( ()=>{
|
||||
@@ -26,16 +25,7 @@ const NavTop = ()=>{
|
||||
|
||||
},})
|
||||
|
||||
const onLogout = ()=>{
|
||||
localStorage.removeItem('0a');
|
||||
localStorage.removeItem('0r');
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
document.cookie = "isAuth=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
setTimeout(()=>{
|
||||
router.push('/login')
|
||||
}, 200)
|
||||
}
|
||||
const onLogout = () => { void logout() }
|
||||
return (
|
||||
<div className="flex flex-row pl-52 justify-between items-center menu fixed bg-slate-600 w-full z-[1000] h-10 mr-52">
|
||||
<div></div>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/axios/axiosInstance'
|
||||
import { canManageProducts } from '@/lib/catalog'
|
||||
import Link from "next/link";
|
||||
import { AiFillAppstore, AiFillHome } from "react-icons/ai";
|
||||
import { HiShoppingCart } from "react-icons/hi";
|
||||
@@ -11,6 +15,7 @@ import { IoNotifications } from "react-icons/io5";
|
||||
|
||||
|
||||
const SideRight = ()=>{
|
||||
const info = useQuery({ queryKey: ['cuser-info'], queryFn: () => api.get('cuser/info/'), retry: false })
|
||||
|
||||
const menuLinks = [
|
||||
{name:'خانه', link: '/', size:25, icon: <AiFillHome size={25} color={'white'} />},
|
||||
@@ -25,7 +30,7 @@ const SideRight = ()=>{
|
||||
{name:'چک ها', link: '', size:25, icon: <FaMoneyCheckDollar size={25} color={'white'} />},
|
||||
{name:'واریزی ها', link: '', size:25, icon: <FaHandHoldingDollar size={25} color={'white'} />},
|
||||
{name:'پایگاه دانش', link: '', size:25, icon: <BsFillQuestionCircleFill size={25} color={'white'} />},
|
||||
|
||||
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -34,14 +39,15 @@ const SideRight = ()=>{
|
||||
<div className="flex flex-col relative pr-2 pl-2">
|
||||
|
||||
<div className="logo mb-4 text-white text-center pt-[1rem] border-b-[1px] pb-4">
|
||||
لوله گستر گلپایگان
|
||||
لوله گستر گلپایگان
|
||||
<p className="font-light text-sm mt-2 ">پنل کارشناسان</p>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<ul>
|
||||
{canManageProducts(info.data?.data) && <Link href="/products"><li className="bg-slate-600 p-2 flex gap-2 mb-4 rounded text-white"><AiFillAppstore size={25} /><span>مدیریت محصولات</span></li></Link>}
|
||||
{menuLinks.map((item)=>{
|
||||
return <Link href={item.link}>
|
||||
return <Link key={item.name} href={item.link}>
|
||||
<li className="bg-slate-600 p-2 flex flex-row gap-x-2 mb-4 rounded-md">
|
||||
|
||||
{item.icon}
|
||||
|
||||
@@ -49,7 +49,7 @@ const customerPageID = ({params}:{params: {id: number} }) => {
|
||||
|
||||
const [summary, setSummary] = useState<ISummary | null>(null)
|
||||
|
||||
const result = useQuery({ queryKey:['customer-summary'], queryFn: async ()=>{
|
||||
const result = useQuery({ queryKey:['customer-summary', params.id], queryFn: async ()=>{
|
||||
const summary = await axiosInstance.get(`customer/${params.id}/summary/`)
|
||||
return { summary: summary,}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FaRegEdit } from "react-icons/fa";
|
||||
@@ -59,8 +62,8 @@ interface IProps {
|
||||
}
|
||||
|
||||
interface IShahr {
|
||||
id: Number,
|
||||
name: String
|
||||
id: number,
|
||||
name: string
|
||||
is_ostan: boolean,
|
||||
"ostan": any
|
||||
}
|
||||
@@ -77,6 +80,8 @@ interface ICustomerForm {
|
||||
}
|
||||
|
||||
const CustomerInfo = (props: IProps)=>{
|
||||
const queryClient = useQueryClient()
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const [edit, setEdit] = useState<boolean>(false)
|
||||
const [shahr, setShahr] = useState<IShahr[] | []>([])
|
||||
|
||||
@@ -121,6 +126,7 @@ const CustomerInfo = (props: IProps)=>{
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ICustomerForm> = async (data) => {
|
||||
setSubmitError('')
|
||||
|
||||
try {
|
||||
|
||||
@@ -128,6 +134,7 @@ const CustomerInfo = (props: IProps)=>{
|
||||
const result = await axiosInstance.patch(`/customer/update/${props.customer.id}/`, data)
|
||||
|
||||
if(result.status == 200){
|
||||
await queryClient.invalidateQueries({ queryKey: ['customer-summary'] })
|
||||
setEdit(false)
|
||||
setTimeout(()=>{
|
||||
alert("با موفقیت ویرایش شد")
|
||||
@@ -138,7 +145,7 @@ const CustomerInfo = (props: IProps)=>{
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setSubmitError(apiErrorMessage(error))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -188,6 +195,7 @@ const CustomerInfo = (props: IProps)=>{
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-3 p-4">
|
||||
{submitError && <p role="alert" className="bg-red-100 text-red-800 rounded p-2">{submitError}</p>}
|
||||
|
||||
|
||||
<div className="name_family">
|
||||
@@ -285,7 +293,7 @@ const CustomerInfo = (props: IProps)=>{
|
||||
</div>
|
||||
|
||||
{edit ? <Button className="bg-blue-900 text-white rounded-md">ویرایش</Button> : null}
|
||||
{edit ? <Button onClick={()=>{setEdit(false)}} className="bg-red-400 text-white rounded-md hover:bg-red-600">لغو</Button> : null}
|
||||
{edit ? <Button type="button" onClick={()=>{setEdit(false)}} className="bg-red-400 text-white rounded-md hover:bg-red-600">لغو</Button> : null}
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FaRegEdit } from "react-icons/fa";
|
||||
@@ -73,10 +76,12 @@ interface IShahr {
|
||||
}
|
||||
|
||||
interface IForm {
|
||||
status: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
const CustomerOnlinePanel = (props: IProps)=>{
|
||||
const queryClient = useQueryClient()
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const [edit, setEdit] = useState<boolean>(false)
|
||||
const [shahr, setShahr] = useState<IShahr[] | []>([])
|
||||
|
||||
@@ -96,6 +101,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<IForm> = async (data) => {
|
||||
setSubmitError('')
|
||||
|
||||
try {
|
||||
|
||||
@@ -103,10 +109,11 @@ const CustomerOnlinePanel = (props: IProps)=>{
|
||||
const result = await axiosInstance.post(`/customer/customer-panel/`, {
|
||||
customer_id: props.customer.id,
|
||||
mobile: props.customer.mobile,
|
||||
status: data.status
|
||||
status: String(data.status) === 'true'
|
||||
})
|
||||
|
||||
if(result.status == 200){
|
||||
await queryClient.invalidateQueries({ queryKey: ['customer-summary'] })
|
||||
setEdit(false)
|
||||
setTimeout(()=>{
|
||||
alert("با موفقیت ثبت شد")
|
||||
@@ -116,7 +123,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setSubmitError(apiErrorMessage(error))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -124,9 +131,9 @@ const CustomerOnlinePanel = (props: IProps)=>{
|
||||
useEffect(()=>{
|
||||
|
||||
if(props.customer?.cuser_role?.customer_panel){
|
||||
setValue('status', props.customer?.cuser_role?.customer_panel)
|
||||
setValue('status', String(props.customer?.cuser_role?.customer_panel))
|
||||
}else{
|
||||
setValue('status', false)
|
||||
setValue('status', 'false')
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +153,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-3 p-4">
|
||||
{submitError && <p role="alert" className="bg-red-100 text-red-800 rounded p-2">{submitError}</p>}
|
||||
|
||||
|
||||
<div className="shive-mantaghe flex flex-col gap-x-3">
|
||||
@@ -175,7 +183,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
|
||||
</div>
|
||||
|
||||
{edit ? <Button className="bg-blue-900 text-white rounded-md">ویرایش</Button> : null}
|
||||
{edit ? <Button onClick={()=>{setEdit(false)}} className="bg-red-400 text-white rounded-md hover:bg-red-600">لغو</Button> : null}
|
||||
{edit ? <Button type="button" onClick={()=>{setEdit(false)}} className="bg-red-400 text-white rounded-md hover:bg-red-600">لغو</Button> : null}
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import CustomerNewHeader from "../components/customer-new-header"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -64,6 +66,7 @@ interface ICustomerForm {
|
||||
|
||||
const newCustomerPage = ()=>{
|
||||
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const [formItems, setFormItems] = useState<IFormItems | null>(null)
|
||||
const [shahr, setShahr] = useState<IShahr[] | null>(null)
|
||||
const router = useRouter()
|
||||
@@ -79,6 +82,7 @@ const newCustomerPage = ()=>{
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ICustomerForm> = async (data) => {
|
||||
setSubmitError('')
|
||||
|
||||
try {
|
||||
const result = await axiosInstance.post('/customer/v2/create/', data)
|
||||
@@ -90,7 +94,7 @@ const newCustomerPage = ()=>{
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setSubmitError(apiErrorMessage(error))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -101,7 +105,7 @@ const newCustomerPage = ()=>{
|
||||
return item.ostan?.id == e.target.value
|
||||
})
|
||||
|
||||
setShahr(tmp_shahr)
|
||||
setShahr(tmp_shahr || null)
|
||||
|
||||
}
|
||||
|
||||
@@ -134,6 +138,7 @@ const newCustomerPage = ()=>{
|
||||
<div className="flex flex-col gap-y-2 p-4">
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col w-full">
|
||||
{submitError && <p role="alert" className="bg-red-100 text-red-800 rounded p-2">{submitError}</p>}
|
||||
|
||||
<div className="title grid grid-cols-4 gap-x-8 gap-y-4">
|
||||
<div>
|
||||
@@ -216,7 +221,7 @@ const newCustomerPage = ()=>{
|
||||
<AlertTitle>خطا</AlertTitle>
|
||||
<AlertDescription>
|
||||
{Object.keys(errors).map((item, key)=>{
|
||||
return <p key={key}>{errors[item]?.message}</p>
|
||||
return <p key={key}>{errors[item as keyof ICustomerForm]?.message}</p>
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Category, listCategories, saveCategory, deleteCategory, integerInput, categoryLabel } from '@/lib/catalog'
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const client = useQueryClient(), categories = useQuery({ queryKey: ['managed-categories'], queryFn: listCategories, retry: false })
|
||||
const [editor, setEditor] = useState<Category | 'new' | null>(null), [search, setSearch] = useState('')
|
||||
const [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('')
|
||||
const [clearImage, setClearImage] = useState(false)
|
||||
const rows = categories.data || []
|
||||
function edit(category: Category | 'new') { setEditor(category); setClearImage(false); setError(''); setNotice('') }
|
||||
async function refresh() { await Promise.all([client.invalidateQueries({ queryKey: ['managed-categories'] }), client.invalidateQueries({ queryKey: ['managed-products'] })]) }
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
const data = new FormData(event.currentTarget)
|
||||
try {
|
||||
const order = integerInput(String(data.get('order_cat')), 'ترتیب نمایش', true)
|
||||
const excel = integerInput(String(data.get('excel_order')), 'ترتیب اکسل', true)
|
||||
const image = data.get('image') as File | null
|
||||
await saveCategory({ name: String(data.get('name')).trim(), parent: data.get('parent') ? Number(data.get('parent')) : null,
|
||||
order_cat: order === null ? null : Number(order), excel_order: excel === null ? null : Number(excel) },
|
||||
editor && editor !== 'new' ? editor.id : undefined, image?.size ? image : null, clearImage)
|
||||
setEditor(null); setNotice('دسته ذخیره شد.'); await refresh()
|
||||
} catch (failure) { setError(apiErrorMessage(failure)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
async function remove(category: Category) {
|
||||
if (!window.confirm(`دسته «${category.name}» حذف شود؟ دسته دارای محصول یا زیردسته قابل حذف نیست.`)) return
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try { await deleteCategory(category.id); setNotice('دسته حذف شد.'); await refresh() }
|
||||
catch (failure) { setError(apiErrorMessage(failure)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const current = editor && editor !== 'new' ? editor : undefined
|
||||
return <div>
|
||||
<header className="flex flex-wrap justify-between gap-3 mb-4"><div><h1 className="text-xl font-bold">مدیریت دستههای محصولات</h1><p className="text-sm text-slate-600">{rows.length} دسته؛ ترتیب نمایش و خروجی اکسل مستقل است.</p></div><button disabled={busy} onClick={() => edit('new')} className="bg-slate-800 text-white rounded px-4 py-2">افزودن دسته</button></header>
|
||||
{editor && <form key={current?.id || 'new'} onSubmit={submit} className="rounded bg-white p-4 mb-4 border" aria-label="فرم دسته">
|
||||
<h2 className="font-bold mb-3">{current ? `ویرایش ${current.name}` : 'دسته جدید'}</h2>
|
||||
<fieldset disabled={busy} className="grid md:grid-cols-2 gap-3">
|
||||
<label>نام دسته<input name="name" required maxLength={60} defaultValue={current?.name} className="w-full border rounded p-2" /></label>
|
||||
<label>دسته والد<select name="parent" defaultValue={current?.parent || ''} className="w-full border rounded p-2"><option value="">بدون والد</option>{rows.filter(item => item.id !== current?.id).map(item => <option value={item.id} key={item.id}>{categoryLabel(item, rows)}</option>)}</select></label>
|
||||
<label>ترتیب نمایش<input name="order_cat" inputMode="numeric" defaultValue={current?.order_cat ?? ''} className="w-full border rounded p-2" /></label><label>ترتیب مستقل اکسل<input name="excel_order" inputMode="numeric" defaultValue={current?.excel_order ?? ''} className="w-full border rounded p-2" /></label>
|
||||
<label>تصویر دسته<input type="file" name="image" accept="image/png,image/jpeg,image/webp" className="w-full border rounded p-2" />{current?.image && <a href={current.image} target="_blank" rel="noreferrer" className="underline">مشاهده تصویر فعلی</a>}</label>
|
||||
{current?.image && <label><input type="checkbox" checked={clearImage} onChange={event => setClearImage(event.target.checked)} /> حذف تصویر فعلی (اگر تصویر جدید انتخاب نشود)</label>}
|
||||
</fieldset><div className="flex gap-2 mt-4"><button disabled={busy} className="bg-slate-800 text-white rounded px-4 py-2">{busy ? 'در حال ذخیره…' : 'ذخیره دسته'}</button><button type="button" disabled={busy} onClick={() => setEditor(null)} className="border rounded px-4 py-2">لغو</button></div>
|
||||
</form>}
|
||||
{notice && <p role="status" className="bg-green-100 p-3 rounded mb-3">{notice}</p>}
|
||||
{(error || categories.isError) && <p role="alert" className="bg-red-100 text-red-800 p-3 rounded mb-3">{error || apiErrorMessage(categories.error)} <button className="underline" onClick={() => categories.refetch()}>تلاش دوباره</button></p>}
|
||||
<label className="block mb-3">جستوجوی دسته<input value={search} onChange={event => setSearch(event.target.value)} placeholder="نام یا مسیر دسته" className="border rounded p-2 mr-3" /></label>
|
||||
<div className="bg-white rounded overflow-x-auto"><table className="w-full text-right"><thead className="bg-slate-700 text-white"><tr>{['مسیر دسته', 'محصول', 'زیردسته', 'ترتیب نمایش', 'ترتیب اکسل', 'عملیات'].map(label => <th key={label} className="p-3">{label}</th>)}</tr></thead><tbody>
|
||||
{rows.filter(item => categoryLabel(item, rows).includes(search)).map(item => <tr key={item.id} className="border-b"><td className="p-3">{categoryLabel(item, rows)}</td><td className="p-3">{item.product_count ?? 0}</td><td className="p-3">{item.child_count ?? 0}</td><td className="p-3">{item.order_cat ?? '—'}</td><td className="p-3">{item.excel_order ?? 'نمایش'}</td><td className="p-3 whitespace-nowrap"><button disabled={busy} onClick={() => edit(item)} className="border rounded p-2">ویرایش</button><button disabled={busy} onClick={() => void remove(item)} className="border rounded p-2 mr-2 text-red-700">حذف</button></td></tr>)}
|
||||
</tbody></table>{categories.isLoading ? <p role="status" className="p-4">در حال دریافت دستهها…</p> : !rows.length && !categories.isError ? <p className="p-4">هنوز دستهای ساخته نشده است.</p> : null}</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { Product, Category, saveProduct, integerInput, categoryLabel } from '@/lib/catalog'
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
|
||||
export default function ProductForm({ product, categories, onSaved, onCancel }: { product?: Product; categories: Category[]; onSaved: () => void; onCancel: () => void }) {
|
||||
const [error, setError] = useState(''), [busy, setBusy] = useState(false)
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
const data = new FormData(event.currentTarget)
|
||||
try {
|
||||
let attr = null
|
||||
const raw = String(data.get('attr') || '').trim()
|
||||
if (raw) {
|
||||
try { attr = JSON.parse(raw) } catch { throw new Error('ویژگیها باید JSON معتبر باشد.') }
|
||||
if (!attr || typeof attr !== 'object' || Array.isArray(attr)) throw new Error('ویژگیها باید یک شیء JSON باشد.')
|
||||
}
|
||||
const order = integerInput(String(data.get('order_prd')), 'ترتیب نمایش', true)
|
||||
const excelOrder = integerInput(String(data.get('excel_order')), 'ترتیب اکسل', true)
|
||||
await saveProduct({ name: String(data.get('name')).trim(), price: integerInput(String(data.get('price')), 'قیمت', false, '9223372036854775807')!,
|
||||
category: Number(data.get('category')), acc_code: String(data.get('acc_code') || '').trim(), attr,
|
||||
view_only: data.get('view_only') === 'on', order_prd: order === null ? null : Number(order), excel_order: excelOrder === null ? null : Number(excelOrder) }, product?.id)
|
||||
onSaved()
|
||||
} catch (failure) { setError(apiErrorMessage(failure)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return <form onSubmit={submit} className="rounded bg-white p-4 border mb-5" aria-label={product ? 'ویرایش محصول' : 'افزودن محصول'}>
|
||||
<h2 className="font-bold text-lg mb-3">{product ? `ویرایش ${product.name}` : 'محصول جدید'}</h2>
|
||||
<fieldset disabled={busy} className="grid md:grid-cols-2 gap-4">
|
||||
<label>نام محصول<input name="name" required maxLength={200} defaultValue={product?.name} className="w-full border rounded p-2" /></label>
|
||||
<label>کد محصول<input name="acc_code" defaultValue={product?.acc_code || ''} className="w-full border rounded p-2" /><span className="text-xs text-slate-600">برای تطبیق ورود اکسل، کد یکتا و ثابت لازم است.</span></label>
|
||||
<label>قیمت<input name="price" required inputMode="numeric" defaultValue={product?.price ?? '0'} className="w-full border rounded p-2" /></label>
|
||||
<label>دسته<select name="category" required defaultValue={product?.category || ''} className="w-full border rounded p-2"><option value="">انتخاب دسته</option>{categories.map(item => <option key={item.id} value={item.id}>{categoryLabel(item, categories)}</option>)}</select></label>
|
||||
<label>ترتیب نمایش<input name="order_prd" inputMode="numeric" defaultValue={product?.order_prd ?? ''} className="w-full border rounded p-2" /></label>
|
||||
<label>ترتیب مستقل اکسل<input name="excel_order" inputMode="numeric" defaultValue={product?.excel_order ?? ''} className="w-full border rounded p-2" /><span className="text-xs text-slate-600">خالی: استفاده از ترتیب نمایش.</span></label>
|
||||
<label className="md:col-span-2">ویژگیها (JSON)<textarea name="attr" dir="ltr" defaultValue={product?.attr ? JSON.stringify(product.attr, null, 2) : ''} rows={3} className="w-full border rounded p-2" placeholder='{"acc_vahed":"عدد"}' /></label>
|
||||
<label><input name="view_only" type="checkbox" defaultChecked={!!product?.view_only} /> فقط نمایش در پنل مشتریان</label>
|
||||
</fieldset>
|
||||
{!categories.length && <p className="text-amber-800 mt-3">ابتدا یک دسته در «مدیریت دستهها» بسازید.</p>}
|
||||
{error && <p role="alert" className="bg-red-100 text-red-800 rounded p-3 mt-3">{error}</p>}
|
||||
<div className="flex gap-2 mt-4"><button disabled={busy || !categories.length} className="rounded bg-slate-800 text-white px-4 py-2 disabled:opacity-50">{busy ? 'در حال ذخیره…' : 'ذخیره'}</button><button type="button" disabled={busy} onClick={onCancel} className="border rounded px-4 py-2">لغو</button></div>
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ImportBatch, previewImport, confirmImport, getImport, listImports, downloadCatalog } from '@/lib/catalog'
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
|
||||
export default function ProductImportPage() {
|
||||
const client = useQueryClient()
|
||||
const [batch, setBatch] = useState<ImportBatch | null>(null), [busy, setBusy] = useState(false), [error, setError] = useState(''), [historyPage, setHistoryPage] = useState(1)
|
||||
const history = useQuery({ queryKey: ['product-imports', historyPage], queryFn: () => listImports(historyPage), retry: false })
|
||||
async function run(action: () => Promise<unknown>) {
|
||||
setBusy(true); setError('')
|
||||
try { await action() }
|
||||
catch (failure) { setError(apiErrorMessage(failure)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
async function upload(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget)
|
||||
data.set('create_categories', data.get('create_categories') === 'on' ? 'true' : 'false')
|
||||
data.set('mapping', String(data.get('mapping') || '').trim() || '{}')
|
||||
setBatch(null)
|
||||
await run(async () => { setBatch(await previewImport(data)); await client.invalidateQueries({ queryKey: ['product-imports'] }) })
|
||||
}
|
||||
async function confirm() {
|
||||
if (!batch || !window.confirm(batch.options.errors === 'skip' ? 'فقط ردیفهای معتبر ثبت شوند؟ ردیفهای نامعتبر در گزارش باقی میمانند.' : 'محصولات و دستههای این پیشنمایش ثبت نهایی شوند؟')) return
|
||||
await run(async () => { setBatch(await confirmImport(batch.id)); await Promise.all([client.invalidateQueries({ queryKey: ['product-imports'] }), client.invalidateQueries({ queryKey: ['managed-products'] }), client.invalidateQueries({ queryKey: ['managed-categories'] })]) })
|
||||
}
|
||||
const button = 'border rounded px-3 py-2 disabled:opacity-50'
|
||||
return <div>
|
||||
<header className="flex flex-wrap justify-between gap-3 mb-4"><h1 className="font-bold text-xl">ورود محصولات از اکسل</h1><button disabled={busy} className={button} onClick={() => void run(() => downloadCatalog('template'))}>دریافت فایل نمونه</button></header>
|
||||
<div className="bg-blue-50 rounded p-4 mb-4 text-sm"><p>فایل xlsx تا ۲ مگابایت، حداکثر ۵۰۰۰ ردیف و ۱۰۰ ستون پذیرفته میشود. ابتدا پیشنمایش ساخته میشود و ثبت نهایی نیازمند تأیید شماست.</p><p>ستونهای نام محصول، دسته و کد الزامیاند. مسیر دسته با / جدا میشود. کدهای دارای صفر ابتدایی را در Excel به صورت متن ذخیره کنید. فرمول را به مقدار ثابت تبدیل کنید.</p><p>قیمت محصول جدید بدون قیمت صفر است. فیلدهای خالی مقدار قبلی را حفظ میکنند. ترتیب دسته و اکسل با ستونهای فایل قابل تنظیم است؛ پیشنمایش ۳۰ دقیقه اعتبار دارد.</p></div>
|
||||
<form onSubmit={upload} className="bg-white rounded p-4 mb-4" aria-label="بارگذاری اکسل">
|
||||
<fieldset disabled={busy} className="grid md:grid-cols-2 gap-4">
|
||||
<label>فایل اکسل<input type="file" name="file" accept=".xlsx" required className="w-full border rounded p-2" /></label><label>نام برگه (اختیاری)<input name="sheet" className="w-full border rounded p-2" /></label>
|
||||
<label>شماره ردیف سرستون<input name="header_row" type="number" min={1} max={100} defaultValue={1} required className="w-full border rounded p-2" /></label>
|
||||
<label>نوع عملیات<select name="mode" className="w-full border rounded p-2"><option value="create">فقط ایجاد محصول جدید</option><option value="upsert">ایجاد و بهروزرسانی</option><option value="update">فقط بهروزرسانی</option></select></label>
|
||||
<label>مدیریت خطا<select name="errors" className="w-full border rounded p-2"><option value="atomic">هر خطا: هیچ ردیفی ثبت نشود</option><option value="skip">فقط ردیفهای معتبر ثبت شوند</option></select></label>
|
||||
<label>ترتیب نمایش محصول<select name="ordering" className="w-full border rounded p-2"><option value="preserve">مقدار فایل؛ خالی = حفظ ترتیب</option><option value="rows">شماره ردیف فایل برای مقدارهای خالی</option><option value="append">محصول جدید در انتهای دسته</option></select></label>
|
||||
<label><input type="checkbox" name="create_categories" defaultChecked /> ساخت خودکار دستههای لازم</label>
|
||||
<label className="md:col-span-2">نگاشت دلخواه ستونها (JSON اختیاری)<textarea name="mapping" dir="ltr" rows={2} className="w-full border rounded p-2" placeholder='{"name":"عنوان کالا","category":"گروه","acc_code":"شناسه"}' /></label>
|
||||
</fieldset><button disabled={busy} className="bg-slate-800 text-white rounded px-4 py-2 mt-4">{busy ? 'در حال پردازش…' : 'اعتبارسنجی و پیشنمایش'}</button>
|
||||
</form>
|
||||
{error && <p role="alert" className="bg-red-100 text-red-800 rounded p-3 mb-4">{error} اگر ارتباط هنگام ثبت نهایی قطع شد، «دریافت وضعیت تازه» را بزنید؛ تأیید همان سابقه دوباره محصول ایجاد نمیکند.</p>}
|
||||
{batch && <section className="bg-white rounded p-4 mb-5" aria-label="نتیجه ورود اکسل"><h2 className="font-bold">{batch.committed ? 'ثبت نهایی انجام شد' : 'پیشنمایش؛ هنوز ثبت نهایی نشده'} — {batch.filename} — شماره {batch.id}</h2>
|
||||
<p className="mt-2">ایجاد: {batch.created} | بهروزرسانی: {batch.updated} | خطا: {batch.errors} | کل ردیفها: {batch.row_count}</p>
|
||||
<p className="text-sm text-slate-600">پیش از ثبت، تعدادها مربوط به عملیات پیشنهادی هستند. مهلت تأیید: {new Date(batch.expires_at).toLocaleString('fa-IR')}</p>
|
||||
<div className="flex flex-wrap gap-2 my-3"><button disabled={busy || !batch.can_confirm} className="bg-green-800 text-white rounded px-3 py-2 disabled:opacity-50" onClick={() => void confirm()}>تأیید و ثبت نهایی</button><button disabled={busy} className={button} onClick={() => void run(() => downloadCatalog('report', batch.id))}>دانلود گزارش همه ردیفها</button><button disabled={busy} className={button} onClick={() => void run(async () => setBatch(await getImport(batch.id, batch.row_page)))}>دریافت وضعیت تازه</button></div>
|
||||
{!batch.can_confirm && !batch.committed && <p className="text-amber-900 mb-3">پیشنمایش دارای خطای مانع ثبت یا منقضیشده است. فایل و تنظیمات را اصلاح و پیشنمایش تازه بسازید.</p>}
|
||||
<div className="overflow-x-auto"><table className="w-full text-right"><thead><tr>{['ردیف فایل', 'کد', 'محصول', 'دسته', 'عملیات', 'خطا'].map(name => <th key={name} className="p-2 bg-slate-100">{name}</th>)}</tr></thead><tbody>{batch.rows.map(row => <tr key={row.row} className="border-b"><td className="p-2">{row.row}</td><td className="p-2">{row.values.acc_code}</td><td className="p-2">{row.values.name}</td><td className="p-2">{row.values.category}</td><td className="p-2">{row.status === 'error' ? 'خطا' : row.status === 'created' ? 'ایجاد' : 'بهروزرسانی'}</td><td className="p-2 text-red-800">{Object.entries(row.errors).map(([field, messages]) => <div key={field}>{field}: {Array.isArray(messages) ? messages.join('، ') : messages}</div>)}</td></tr>)}</tbody></table></div>
|
||||
<div className="flex gap-3 items-center mt-3"><button disabled={busy || batch.row_page <= 1} className={button} onClick={() => void run(async () => setBatch(await getImport(batch.id, batch.row_page - 1)))}>ردیفهای قبلی</button><span>صفحه ردیف {batch.row_page} از {Math.max(1, Math.ceil(batch.row_count / 100))}</span><button disabled={busy || batch.row_page * 100 >= batch.row_count} className={button} onClick={() => void run(async () => setBatch(await getImport(batch.id, batch.row_page + 1)))}>ردیفهای بعدی</button></div>
|
||||
</section>}
|
||||
<section className="bg-white rounded p-4"><h2 className="font-bold mb-3">سوابق ورودهای شما در فضای کاری فعلی</h2>{history.isLoading && <p role="status">در حال دریافت سوابق…</p>}{history.isError && <p role="alert">{apiErrorMessage(history.error)} <button className="underline" onClick={() => history.refetch()}>تلاش دوباره</button></p>}
|
||||
{!history.isLoading && !history.isError && !history.data?.results.length && <p>هنوز سابقهای وجود ندارد.</p>}
|
||||
{history.data?.results.map(item => <div key={item.id} className="border-b py-3 flex flex-wrap gap-3 justify-between"><span>#{item.id} — {item.filename} — {item.committed ? 'ثبتشده' : 'پیشنمایش'} — {item.errors} خطا</span><button disabled={busy} className={button} onClick={() => void run(async () => setBatch(await getImport(item.id)))}>مشاهده و گزارش</button></div>)}
|
||||
<div className="flex gap-3 items-center mt-3"><button disabled={historyPage <= 1 || busy} className={button} onClick={() => setHistoryPage(historyPage - 1)}>قبلی</button><span>صفحه {historyPage}</span><button disabled={!history.data?.next || busy} className={button} onClick={() => setHistoryPage(historyPage + 1)}>بعدی</button></div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/axios/axiosInstance'
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
import { canManageProducts } from '@/lib/catalog'
|
||||
|
||||
export default function ProductsLayout({ children }: { children: ReactNode }) {
|
||||
const access = useQuery({ queryKey: ['cuser-info'], queryFn: () => api.get('cuser/info/'), retry: false, refetchOnWindowFocus: true })
|
||||
if (access.isLoading) return <p className="p-6" role="status">در حال بررسی دسترسی…</p>
|
||||
if (access.isError) return <div className="p-6"><p role="alert">{apiErrorMessage(access.error)}</p><button onClick={() => access.refetch()} className="border rounded p-2">تلاش دوباره</button></div>
|
||||
if (!canManageProducts(access.data?.data)) return <p className="p-6 text-red-800" role="alert">مدیریت محصولات فقط برای مدیر فضای کاری مجاز است.</p>
|
||||
return <section className="p-3 md:p-6" dir="rtl"><nav className="flex flex-wrap gap-3 mb-5 rounded bg-white p-3" aria-label="مدیریت فروشگاه">
|
||||
<Link href="/products">محصولات</Link><Link href="/products/categories">مدیریت دستهها</Link><Link href="/products/import">ورود اکسل و سوابق</Link>
|
||||
</nav>{children}</section>
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listProducts, listCategories, Product, deleteProduct, bulkProducts, downloadCatalog, categoryLabel } from '@/lib/catalog'
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
import ProductForm from './components/product-form'
|
||||
|
||||
export default function ProductsPage() {
|
||||
const client = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, string | number>>({ ordering: 'display' })
|
||||
const [page, setPage] = useState(1), [editor, setEditor] = useState<Product | 'new' | null>(null)
|
||||
const [selected, setSelected] = useState<number[]>([]), [error, setError] = useState(''), [notice, setNotice] = useState(''), [busy, setBusy] = useState(false)
|
||||
const [destination, setDestination] = useState('')
|
||||
const products = useQuery({ queryKey: ['managed-products', filters, page], queryFn: () => listProducts({ ...filters, page }), retry: false })
|
||||
const categories = useQuery({ queryKey: ['managed-categories'], queryFn: listCategories, retry: false })
|
||||
const rows = products.data?.results || []
|
||||
const totalPages = Math.max(1, Math.ceil((products.data?.count || 0) / 25))
|
||||
async function refresh() { await Promise.all([client.invalidateQueries({ queryKey: ['managed-products'] }), client.invalidateQueries({ queryKey: ['managed-categories'] })]) }
|
||||
async function run(action: () => Promise<unknown>, message: string) {
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try { await action(); setSelected([]); setPage(1); setNotice(message); await refresh() }
|
||||
catch (failure) { setError(apiErrorMessage(failure)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
function search(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget), next: Record<string, string> = {}
|
||||
for (const [key, value] of Array.from(data.entries())) if (String(value).trim()) next[key] = String(value).trim()
|
||||
setFilters(next); setPage(1); setSelected([])
|
||||
}
|
||||
function remove(product: Product) {
|
||||
if (window.confirm(`محصول «${product.name}» حذف شود؟ محصول استفادهشده در سفارش یا سبد قابل حذف نیست.`)) void run(() => deleteProduct(product.id), 'محصول حذف شد.')
|
||||
}
|
||||
function bulk(action: string, values = {}) {
|
||||
if (!selected.length) return
|
||||
if (action === 'delete' && !window.confirm(`${selected.length} محصول حذف شوند؟ اگر یکی قابل حذف نباشد، هیچ محصولی حذف نمیشود.`)) return
|
||||
void run(() => bulkProducts(selected, action, values), 'عملیات گروهی انجام شد.')
|
||||
}
|
||||
const button = 'border rounded px-3 py-2 disabled:opacity-50'
|
||||
return <div>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 mb-4"><div><h1 className="font-bold text-xl">مدیریت محصولات</h1><p className="text-sm text-slate-600">{products.data?.count ?? '…'} محصول در فضای کاری فعلی</p></div>
|
||||
<div className="flex flex-wrap gap-2"><button disabled={busy} className="bg-slate-800 text-white rounded px-3 py-2" onClick={() => { setEditor('new'); setError('') }}>افزودن محصول</button><Link className={button} href="/products/import">ورود از اکسل</Link><button disabled={busy} className={button} onClick={() => void run(() => downloadCatalog('products', undefined, filters), 'خروجی اکسل دریافت شد.')}>خروجی فهرست فیلترشده</button></div>
|
||||
</header>
|
||||
{editor && <ProductForm key={editor === 'new' ? 'new' : editor.id} product={editor === 'new' ? undefined : editor} categories={categories.data || []} onCancel={() => setEditor(null)} onSaved={() => { setEditor(null); setNotice('محصول ذخیره شد.'); void refresh() }} />}
|
||||
<form onSubmit={search} className="grid md:grid-cols-3 gap-3 bg-white rounded p-4 mb-4">
|
||||
<label>جستوجو<input name="search" placeholder="نام، کد یا دسته" className="w-full border rounded p-2" /></label>
|
||||
<label>دسته<select name="category" className="w-full border rounded p-2"><option value="">همه دستهها</option>{categories.data?.map(item => <option key={item.id} value={item.id}>{categoryLabel(item, categories.data!)}</option>)}</select></label>
|
||||
<label>ترتیب فهرست<select name="ordering" defaultValue="display" className="w-full border rounded p-2"><option value="display">ترتیب نمایش</option><option value="excel">ترتیب اکسل</option><option value="name">نام</option><option value="-name">نام معکوس</option><option value="price">قیمت صعودی</option><option value="-price">قیمت نزولی</option><option value="-created_at">جدیدترین</option></select></label>
|
||||
<label>حداقل قیمت<input name="price_min" type="number" min="0" step="1" className="w-full border rounded p-2" /></label><label>حداکثر قیمت<input name="price_max" type="number" min="0" step="1" className="w-full border rounded p-2" /></label>
|
||||
<label>نوع نمایش<select name="view_only" className="w-full border rounded p-2"><option value="">همه</option><option value="true">فقط نمایش</option><option value="false">قابل سفارش</option></select></label>
|
||||
<button className="bg-slate-700 text-white rounded p-2" disabled={busy}>اعمال جستوجو و فیلتر</button>
|
||||
</form>
|
||||
{notice && <p role="status" className="bg-green-100 text-green-900 rounded p-3 mb-3">{notice}</p>}
|
||||
{(error || products.isError || categories.isError) && <div role="alert" className="bg-red-100 text-red-800 rounded p-3 mb-3">{error || apiErrorMessage(products.error || categories.error)}<button className="mr-3 underline" onClick={() => void refresh()}>تلاش دوباره</button></div>}
|
||||
{!!selected.length && <div className="flex flex-wrap gap-2 items-center rounded bg-blue-50 p-3 mb-3"><span>{selected.length} انتخابشده</span>
|
||||
<select aria-label="دسته مقصد عملیات گروهی" value={destination} onChange={event => setDestination(event.target.value)} className="border rounded p-2"><option value="">دسته مقصد</option>{categories.data?.map(item => <option key={item.id} value={item.id}>{categoryLabel(item, categories.data!)}</option>)}</select>
|
||||
<button disabled={busy || !destination} className={button} onClick={() => bulk('move', { category: Number(destination) })}>انتقال</button>
|
||||
<button disabled={busy} className={button} onClick={() => bulk('view_only', { view_only: true })}>فقط نمایش</button><button disabled={busy} className={button} onClick={() => bulk('view_only', { view_only: false })}>قابل سفارش</button>
|
||||
<button disabled={busy} className={button} onClick={() => bulk('display_order')}>شمارهگذاری نمایش ۱ تا N</button><button disabled={busy} className={button} onClick={() => bulk('excel_order')}>شمارهگذاری اکسل ۱ تا N</button>
|
||||
<button disabled={busy} className={button + ' text-red-700'} onClick={() => bulk('delete')}>حذف انتخابشدهها</button><p className="w-full text-xs">شمارهگذاری فقط محصولات انتخابشده را، به ترتیب انتخاب شما، تغییر میدهد.</p>
|
||||
</div>}
|
||||
<div className="overflow-x-auto rounded bg-white"><table className="w-full text-right"><thead className="bg-slate-700 text-white"><tr><th className="p-3"><input type="checkbox" aria-label="انتخاب تمام محصولات این صفحه" checked={!!rows.length && selected.length === rows.length} onChange={event => setSelected(event.target.checked ? rows.map(item => item.id) : [])} /></th>{['محصول / کد', 'دسته', 'قیمت', 'ترتیب نمایش', 'ترتیب اکسل', 'وضعیت', 'عملیات'].map(name => <th className="p-3" key={name}>{name}</th>)}</tr></thead><tbody>
|
||||
{rows.map(product => <tr key={product.id} className="border-b"><td className="p-3"><input type="checkbox" aria-label={`انتخاب ${product.name}`} checked={selected.includes(product.id)} onChange={event => setSelected(previous => event.target.checked ? [...previous, product.id] : previous.filter(id => id !== product.id))} /></td>
|
||||
<td className="p-3">{product.name}<div dir="ltr" className="text-xs text-right text-slate-500">{product.acc_code || 'بدون کد'}</div></td><td className="p-3">{categories.data?.find(item => item.id === product.category) ? categoryLabel(categories.data.find(item => item.id === product.category)!, categories.data) : product.category_name}</td><td className="p-3" dir="ltr">{product.price}</td><td className="p-3">{product.order_prd ?? '—'}</td><td className="p-3">{product.excel_order ?? 'نمایش'}</td><td className="p-3">{product.view_only ? 'فقط نمایش' : 'قابل سفارش'}</td>
|
||||
<td className="p-3 whitespace-nowrap"><button disabled={busy} onClick={() => setEditor(product)} className={button}>ویرایش</button><button disabled={busy} onClick={() => remove(product)} className={button + ' mr-2 text-red-700'}>حذف</button></td></tr>)}
|
||||
</tbody></table>{products.isLoading ? <p role="status" className="p-5">در حال دریافت محصولات…</p> : !rows.length && !products.isError ? <p className="p-5">محصولی با این فیلترها پیدا نشد.</p> : null}</div>
|
||||
<div className="flex gap-3 items-center mt-4"><button disabled={page <= 1 || busy} className={button} onClick={() => { setPage(page - 1); setSelected([]) }}>قبلی</button><span>صفحه {page} از {totalPages}</span><button disabled={!products.data?.next || busy} className={button} onClick={() => { setPage(page + 1); setSelected([]) }}>بعدی</button></div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,120 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import axiosInstance from '@/axios/axiosInstance'
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { log } from 'util';
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
|
||||
function setCookieFn(name:string, value:string, daysToExpire:number) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (daysToExpire * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
||||
}
|
||||
|
||||
const LoginPage = ()=>{
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const username = useRef<any>()
|
||||
const password = useRef<any>()
|
||||
const [showError, setShowError] = useState<boolean>(false)
|
||||
const [loginButton, setLoginButton] = useState<boolean>(true)
|
||||
|
||||
|
||||
|
||||
const loginUser = async ()=>{
|
||||
|
||||
setLoginButton(false)
|
||||
|
||||
const my_username = username.current.value
|
||||
const my_password = password.current.value
|
||||
|
||||
|
||||
|
||||
try{
|
||||
|
||||
const result = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/login/username/`, {
|
||||
username: my_username,
|
||||
password: my_password
|
||||
})
|
||||
|
||||
if(result?.status==200){
|
||||
const uuid = await result.data[0].uuid
|
||||
|
||||
const result_token = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/token/`, {
|
||||
uuid_opt: uuid,
|
||||
})
|
||||
|
||||
if(result_token.status == 200){
|
||||
//console.log(result_token.data.access)
|
||||
//console.log(result_token.data.refresh)
|
||||
localStorage.setItem('0a', result_token.data.access.slice(-2) )
|
||||
localStorage.setItem('0r', result_token.data.refresh.slice(-2) )
|
||||
localStorage.setItem('access', result_token.data.access.slice(0, result_token.data.access.split('').length-2 ))
|
||||
localStorage.setItem('refresh', result_token.data.refresh.slice(0, result_token.data.refresh.split('').length-2 ))
|
||||
setShowError(false)
|
||||
setCookieFn('isAuth', 'true', 365)
|
||||
setTimeout(()=>{
|
||||
router.push('/')
|
||||
}, 200)
|
||||
}else{
|
||||
setLoginButton(false)
|
||||
setShowError(true)
|
||||
}
|
||||
|
||||
} else {
|
||||
setLoginButton(true)
|
||||
setShowError(true)
|
||||
}
|
||||
|
||||
} catch {
|
||||
setLoginButton(true)
|
||||
setShowError(true)
|
||||
}
|
||||
import { apiErrorMessage, LoginOption, obtainTokens } from '@/lib/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [options, setOptions] = useState<LoginOption[]>([])
|
||||
useEffect(() => {
|
||||
const reason = new URLSearchParams(window.location.search).get('reason')
|
||||
if (reason === 'logout-unconfirmed') setError('از این دستگاه خارج شدید؛ به علت قطع ارتباط، ابطال نشست در سرور تأیید نشد.')
|
||||
if (reason === 'session') setError('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.')
|
||||
}, [])
|
||||
|
||||
async function choose(option: LoginOption) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await obtainTokens(option.uuid)
|
||||
window.location.replace('/')
|
||||
} catch (failure) {
|
||||
setError(apiErrorMessage(failure))
|
||||
setOptions([])
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="h-screen flex items-center justify-center p-4 md:p-0 bg-slate-200">
|
||||
<div className="flex flex-col w-full md:w-[50%] lg:w-[30%] border-[1px] border-gray-200 bg-white rounded-lg">
|
||||
|
||||
<div className="flex flex-col gap-y-3 w-full p-4">
|
||||
<h1 className="text-center border-b-[1px] pb-4 font-bold">ورود به سامانه</h1>
|
||||
|
||||
<div className="w-full lg:w-[60%] flex flex-col gap-y-1 m-auto">
|
||||
<label>نام کاربری</label>
|
||||
<input ref={username} className="border-[1px] text-center rounded h-9" type="text" />
|
||||
<label>کلمه عبور</label>
|
||||
<input ref={password} className="border-[1px] text-center rounded h-9" type="password" />
|
||||
{loginButton ? (
|
||||
<button onClick={loginUser} className="button border-[1px] rounded-md mt-5 bg-slate-800 h-9 text-white" >ثبت</button>
|
||||
) : (
|
||||
<p className='text-center text-red-800'>درحال ورود به سیستم لطفا صبر کنید</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{showError ? (
|
||||
<div className='w-full pr-5 pl-5 mb-4'>
|
||||
<div className='bg-red-200 rounded-lg pt-1 pb-1 pr-2 pl-2'>
|
||||
<p>نام کاربری یا کلمه عبور اشتباه است</p>
|
||||
</div>
|
||||
</div>
|
||||
): null}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
async function login(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setOptions([])
|
||||
try {
|
||||
const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/username/', { username, password }, { timeout: 10000 })
|
||||
const roles: LoginOption[] = response.data
|
||||
if (!Array.isArray(roles) || !roles.length) throw new Error('دسترسی فعالی برای ورود وجود ندارد.')
|
||||
if (roles.length === 1) await choose(roles[0])
|
||||
else setOptions(roles)
|
||||
} catch (failure) { setError(apiErrorMessage(failure)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <div className="h-screen flex items-center justify-center p-4 bg-slate-200" dir="rtl">
|
||||
<div className="flex flex-col w-full md:w-1/2 lg:w-1/3 bg-white rounded-lg p-4 gap-y-3">
|
||||
<h1 className="text-center border-b pb-4 font-bold">ورود به سامانه</h1>
|
||||
<form onSubmit={login} className="flex flex-col gap-y-2">
|
||||
<label htmlFor="username">نام کاربری</label>
|
||||
<input id="username" autoComplete="username" required value={username} onChange={e => setUsername(e.target.value)} className="border text-center rounded h-9" />
|
||||
<label htmlFor="password">کلمه عبور</label>
|
||||
<input id="password" autoComplete="current-password" required type="password" value={password} onChange={e => setPassword(e.target.value)} className="border text-center rounded h-9" />
|
||||
<button disabled={busy} className="rounded bg-slate-800 h-9 text-white disabled:opacity-50">{busy ? 'در حال ورود…' : 'ورود'}</button>
|
||||
</form>
|
||||
{options.length > 1 && <div className="flex flex-col gap-2"><p>فضای کاری و نقش را انتخاب کنید:</p>{options.map(option => <button type="button" key={option.uuid} disabled={busy} onClick={() => choose(option)} className="border rounded p-2">{option.cuser_role.sys_ws.name} ـ {option.cuser_role.role_type.role_name}</button>)}</div>}
|
||||
{error && <p role="alert" className="bg-red-100 text-red-800 rounded p-2">{error}</p>}
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)
|
||||
</div>
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
+53
-67
@@ -1,78 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import axios from 'axios'
|
||||
import { error } from 'console';
|
||||
import { cookies, headers } from 'next/headers'
|
||||
|
||||
//let accessToken = localStorage.getItem('access') ? localStorage.getItem('access') : null
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { expireSession, getAccessToken, getRefreshToken, saveTokens } from '@/lib/auth'
|
||||
|
||||
type RetryRequest = InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
class InvalidSession extends Error {}
|
||||
let pendingRefresh: Promise<string> | null = null
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: `${process.env.NEXT_PUBLIC_API_URL}/`,
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL + '/',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
//'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
axiosInstance.interceptors.request.use(async req => {
|
||||
const accessT = await localStorage.getItem('access')
|
||||
const accessTx = await localStorage.getItem('0a')
|
||||
req.headers.Authorization = `Bearer ${accessT}${accessTx}`
|
||||
return req
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.response.use((response)=> {
|
||||
return response
|
||||
}, async (error) =>{
|
||||
axiosInstance.interceptors.request.use(request => {
|
||||
const access = getAccessToken()
|
||||
if (access) request.headers.Authorization = 'Bearer ' + access
|
||||
else delete request.headers.Authorization
|
||||
return request
|
||||
})
|
||||
|
||||
const originalRequest = error.config;
|
||||
|
||||
|
||||
if (error.response.status === 401 && !originalRequest._retry) {
|
||||
|
||||
|
||||
|
||||
originalRequest._retry = true; // Prevent infinite loops
|
||||
|
||||
try {
|
||||
|
||||
const rf_tkn:string | any = await localStorage.getItem('refresh')
|
||||
const rf_p2:string | any = await localStorage.getItem('0r')
|
||||
const rf_token = rf_tkn + rf_p2
|
||||
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/token/refresh/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'refresh': rf_token
|
||||
})
|
||||
});
|
||||
|
||||
if(res.status){
|
||||
const tokenData = await res.json()
|
||||
//localStorage.setItem('access', tokenData.access)
|
||||
//localStorage.setItem('refresh',tokenData.refresh)
|
||||
|
||||
localStorage.setItem('0a', tokenData.access.slice(-2) )
|
||||
localStorage.setItem('0r', tokenData.refresh.slice(-2) )
|
||||
localStorage.setItem('access', tokenData.access.slice(0, tokenData.access.split('').length-2 ))
|
||||
localStorage.setItem('refresh', tokenData.refresh.slice(0, tokenData.refresh.split('').length-2 ))
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${tokenData.access}`
|
||||
return axiosInstance(originalRequest)
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch(refreshError) {
|
||||
throw refreshError;
|
||||
}
|
||||
async function refreshAccess(): Promise<string> {
|
||||
const refresh = getRefreshToken()
|
||||
if (!refresh) throw new InvalidSession()
|
||||
try {
|
||||
const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/refresh/', { refresh }, { timeout: 10000 })
|
||||
if (getRefreshToken() !== refresh) throw new Error('نشست تغییر کرده است.')
|
||||
if (!response.data?.access || !response.data?.refresh) throw new InvalidSession()
|
||||
saveTokens(response.data)
|
||||
return response.data.access
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession()
|
||||
throw error
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default axiosInstance
|
||||
axiosInstance.interceptors.response.use(response => response, async (error: AxiosError) => {
|
||||
const request = error.config as RetryRequest | undefined
|
||||
if (error.response?.status !== 401 || !request || /\/auth\//.test(request.url || '')) return Promise.reject(error)
|
||||
if (request._retry) {
|
||||
const latest = getAccessToken()
|
||||
if (!latest || request.headers.Authorization === 'Bearer ' + latest) expireSession()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
request._retry = true
|
||||
// Another request may already have refreshed the token that this request used.
|
||||
const currentAccess = getAccessToken()
|
||||
if (currentAccess && request.headers.Authorization !== 'Bearer ' + currentAccess) {
|
||||
request.headers.Authorization = 'Bearer ' + currentAccess
|
||||
return axiosInstance(request)
|
||||
}
|
||||
const refresh = getRefreshToken()
|
||||
try {
|
||||
if (!pendingRefresh) pendingRefresh = refreshAccess().finally(() => { pendingRefresh = null })
|
||||
request.headers.Authorization = 'Bearer ' + await pendingRefresh
|
||||
return axiosInstance(request)
|
||||
} catch (refreshError) {
|
||||
if (refreshError instanceof InvalidSession && getRefreshToken() === refresh) expireSession()
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
})
|
||||
|
||||
export default axiosInstance
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const splitTokens = true
|
||||
export type Tokens = { access: string; refresh: string }
|
||||
export type LoginOption = { uuid: string; cuser_role: { name_family: string; title: string; sys_ws: { name: string }; role_type: { role_name: string; role_name_en: string } } }
|
||||
|
||||
function readToken(key: 'access' | 'refresh'): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const value = window.localStorage.getItem(key)
|
||||
if (!value || value === 'undefined' || value === 'null') return null
|
||||
return value + (splitTokens ? window.localStorage.getItem(key === 'access' ? '0a' : '0r') || '' : '')
|
||||
}
|
||||
export const getAccessToken = () => readToken('access')
|
||||
export const getRefreshToken = () => readToken('refresh')
|
||||
|
||||
export function saveTokens(data: Tokens): void {
|
||||
if (!data || typeof data.access !== 'string' || !data.access || typeof data.refresh !== 'string' || !data.refresh) {
|
||||
throw new Error('پاسخ ورود معتبر نیست. دوباره تلاش کنید.')
|
||||
}
|
||||
for (const key of ['access', 'refresh'] as const) {
|
||||
window.localStorage.setItem(key, splitTokens ? data[key].slice(0, -2) : data[key])
|
||||
const suffix = key === 'access' ? '0a' : '0r'
|
||||
if (splitTokens) window.localStorage.setItem(suffix, data[key].slice(-2))
|
||||
else window.localStorage.removeItem(suffix)
|
||||
}
|
||||
document.cookie = 'isAuth=true;max-age=2592000;path=/;SameSite=Lax'
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
for (const key of ['access', 'refresh', '0a', '0r', 'nameFamily']) window.localStorage.removeItem(key)
|
||||
window.sessionStorage.removeItem('loginMobile')
|
||||
document.cookie = 'isAuth=;max-age=0;path=/;SameSite=Lax'
|
||||
}
|
||||
|
||||
export function expireSession(): void {
|
||||
clearSession()
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.replace('/login?reason=session')
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const refresh = getRefreshToken()
|
||||
clearSession() // Pending refresh responses cannot restore this session.
|
||||
let destination = '/login'
|
||||
try {
|
||||
if (refresh) await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/logout/', { refresh }, { timeout: 5000 })
|
||||
} catch {
|
||||
destination = '/login?reason=logout-unconfirmed'
|
||||
} finally {
|
||||
window.location.replace(destination)
|
||||
}
|
||||
}
|
||||
|
||||
function collectMessages(value: unknown): string[] {
|
||||
if (typeof value === 'string') return [value]
|
||||
if (Array.isArray(value)) return value.flatMap(collectMessages)
|
||||
if (value && typeof value === 'object') return Object.values(value).flatMap(collectMessages)
|
||||
return []
|
||||
}
|
||||
|
||||
export function apiErrorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const messages = collectMessages(error.response?.data)
|
||||
return messages.length ? messages.join(' ـ ') : 'ارتباط با سرور برقرار نشد. دوباره تلاش کنید.'
|
||||
}
|
||||
return error instanceof Error ? error.message : 'خطایی رخ داد. دوباره تلاش کنید.'
|
||||
}
|
||||
|
||||
export async function obtainTokens(uuid: string): Promise<void> {
|
||||
const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/', { uuid_opt: uuid }, { timeout: 10000 })
|
||||
saveTokens(response.data)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import axios from 'axios'
|
||||
import api from '@/axios/axiosInstance'
|
||||
import { apiErrorMessage } from '@/lib/auth'
|
||||
|
||||
export type Category = { id: number; name: string; parent: number | null; image: string | null; order_cat: number | null; excel_order: number | null; product_count?: number; child_count?: number }
|
||||
export type Product = { id: number; name: string; price: string; category: number; category_name: string; acc_code: string | null; attr: Record<string, unknown> | null; view_only: boolean | null; order_prd: number | null; excel_order: number | null }
|
||||
export type Page<T> = { count: number; results: T[]; next: string | null; previous: string | null }
|
||||
export type ImportRow = { row: number; values: Record<string, string>; status: string; errors: Record<string, string[] | string> }
|
||||
export type ImportBatch = { id: number; filename: string; created: number; updated: number; errors: number; committed: boolean; can_confirm: boolean; confirmed_at: string | null; expires_at: string; row_count: number; row_page: number; rows: ImportRow[]; options: Record<string, string | boolean> }
|
||||
export type ProductInput = { name: string; price: string; category: number; acc_code: string; attr: Record<string, unknown> | null; view_only: boolean; order_prd: number | null; excel_order: number | null }
|
||||
export type CategoryInput = { name: string; parent: number | null; order_cat: number | null; excel_order: number | null }
|
||||
const root = 'shop/manage/'
|
||||
|
||||
export const canManageProducts = (info: unknown): boolean => !!info && typeof info === 'object' && (info as { permissions?: { manage_products?: unknown } }).permissions?.manage_products === true
|
||||
export const listProducts = async (params: Record<string, string | number>) => (await api.get<Page<Product>>(root + 'products/', { params })).data
|
||||
export const listCategories = async () => (await api.get<Category[]>(root + 'categories/')).data
|
||||
export const saveProduct = async (values: ProductInput, id?: number) => id ? (await api.patch<Product>(root + `products/${id}/`, values)).data : (await api.post<Product>(root + 'products/', values)).data
|
||||
export const deleteProduct = async (id: number) => api.delete(root + `products/${id}/`)
|
||||
export const deleteCategory = async (id: number) => api.delete(root + `categories/${id}/`)
|
||||
export const bulkProducts = async (ids: number[], action: string, values = {}) => (await api.post(root + 'products/bulk/', { ids, action, ...values })).data
|
||||
export const previewImport = async (data: FormData) => (await api.post<ImportBatch>(root + 'import/preview/', data, { timeout: 120000, headers: { 'Content-Type': 'multipart/form-data' } })).data
|
||||
export const confirmImport = async (id: number) => (await api.post<ImportBatch>(root + `imports/${id}/confirm/`, {}, { timeout: 120000 })).data
|
||||
export const getImport = async (id: number, rowPage = 1) => (await api.get<ImportBatch>(root + `imports/${id}/`, { params: { row_page: rowPage } })).data
|
||||
export const listImports = async (page = 1) => (await api.get<Page<ImportBatch>>(root + 'imports/', { params: { page, page_size: 10 } })).data
|
||||
|
||||
export async function saveCategory(values: CategoryInput, id?: number, image?: File | null, clearImage = false): Promise<Category> {
|
||||
const url = root + (id ? `categories/${id}/` : 'categories/')
|
||||
if (!image) {
|
||||
const payload = clearImage ? { ...values, image: null } : values
|
||||
return (await (id ? api.patch<Category>(url, payload) : api.post<Category>(url, payload))).data
|
||||
}
|
||||
const data = new FormData()
|
||||
for (const [key, value] of Object.entries(values)) data.append(key, value == null ? '' : String(value))
|
||||
data.append('image', image)
|
||||
const config = { headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
return (await (id ? api.patch<Category>(url, data, config) : api.post<Category>(url, data, config))).data
|
||||
}
|
||||
|
||||
export async function downloadCatalog(kind: 'products' | 'template' | 'report', id?: number, params?: Record<string, string | number>): Promise<void> {
|
||||
const url = root + (kind === 'products' ? 'products/export/' : kind === 'template' ? 'import/template/' : `imports/${id}/report/`)
|
||||
try {
|
||||
const response = await api.get(url, { responseType: 'blob', params, timeout: 120000 })
|
||||
const objectUrl = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = objectUrl
|
||||
link.download = kind === 'products' ? 'products.xlsx' : kind === 'template' ? 'product-import-template.xlsx' : `import-report-${id}.xlsx`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const text = await error.response.data.text()
|
||||
try { error.response.data = JSON.parse(text) } catch { error.response.data = { detail: 'دریافت فایل انجام نشد. دوباره تلاش کنید.' } }
|
||||
}
|
||||
throw new Error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
export function integerInput(value: string, label: string, optional = false, maximum = '2147483647'): string | null {
|
||||
const normalized = value.trim().replace(/[۰-۹٠-٩]/g, character => String(character.charCodeAt(0) - (character >= '۰' ? 1776 : 1632))).replace(/[,٬]/g, '')
|
||||
if (!normalized && optional) return null
|
||||
if (!/^\d+$/.test(normalized) || BigInt(normalized) > BigInt(maximum)) throw new Error(`${label}: عدد صحیح نامنفی در محدوده مجاز وارد کنید.`)
|
||||
return BigInt(normalized).toString()
|
||||
}
|
||||
|
||||
export function categoryLabel(category: Category, categories: Category[]): string {
|
||||
const names = [category.name], visited = new Set([category.id])
|
||||
let parent = categories.find(item => item.id === category.parent)
|
||||
while (parent && !visited.has(parent.id)) {
|
||||
visited.add(parent.id)
|
||||
names.unshift(parent.name)
|
||||
parent = categories.find(item => item.id === parent?.parent)
|
||||
}
|
||||
return names.join(' / ')
|
||||
}
|
||||
+7
-24
@@ -1,30 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
// This function can be marked `async` if using `await` inside
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
|
||||
let isAuth:any = request.cookies.get('isAuth')
|
||||
|
||||
try {
|
||||
if(isAuth.value != "true"){
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
if (request.cookies.get('isAuth')?.value !== 'true') {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|login|login/verify).*)',
|
||||
],
|
||||
}
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|login|register).*)'],
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const realAxios = require('axios');
|
||||
|
||||
// Execute the actual TypeScript modules against Axios adapters and a browser stub.
|
||||
// No additional runtime/test dependencies, no network, no production API.
|
||||
function harness() {
|
||||
const local = new Map(); const session = new Map(); const redirects = [];
|
||||
const storage = map => ({ getItem: key => map.get(key) ?? null, setItem: (key, value) => map.set(key, String(value)), removeItem: key => map.delete(key) });
|
||||
const window = { localStorage: storage(local), sessionStorage: storage(session), location: { pathname: '/shop', replace: value => redirects.push(value) } };
|
||||
const document = { cookie: '' };
|
||||
const axios = realAxios.create(); axios.create = realAxios.create;
|
||||
axios.isAxiosError = realAxios.isAxiosError;
|
||||
const cache = {};
|
||||
const hooks = []; let cursor = 0;
|
||||
const react = { ...require('react'), useState: initial => {
|
||||
const index = cursor++;
|
||||
if (!(index in hooks)) hooks[index] = initial;
|
||||
return [hooks[index], value => { hooks[index] = typeof value === 'function' ? value(hooks[index]) : value; }];
|
||||
}, useEffect: effect => effect() };
|
||||
const dispatches = [];
|
||||
function render(component) { cursor = 0; return component(); }
|
||||
function load(filename) {
|
||||
const full = path.resolve(filename);
|
||||
if (cache[full]) return cache[full].exports;
|
||||
const module = { exports: {} }; cache[full] = module;
|
||||
const source = ts.transpileModule(fs.readFileSync(full, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||
const requireStub = name => {
|
||||
if (name === 'axios') return axios;
|
||||
if (name === 'react') return react;
|
||||
if (name === 'react/jsx-runtime') return require(name);
|
||||
if (name === 'next/navigation') return { useRouter: () => ({ push: value => redirects.push(value), replace: value => redirects.push(value) }) };
|
||||
if (name === 'react-redux') return { useDispatch: () => action => dispatches.push(action), useSelector: select => select({ mobileopt: { mobile: 9123456789 } }) };
|
||||
if (name.includes('mobileoptSlice')) return { addMobile: payload => ({ payload }) };
|
||||
const base = path.join('src', name.slice(2));
|
||||
return load(base + (fs.existsSync(base + '.ts') ? '.ts' : '.tsx'));
|
||||
};
|
||||
vm.runInNewContext('(function(require, module, exports){' + source + '\n})', { window, document, process, Error, Promise, URLSearchParams, console })(requireStub, module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
const auth = load('src/lib/auth.ts');
|
||||
const instance = load('src/axios/axiosInstance.ts').default;
|
||||
return { auth, instance, axios, local, session, redirects, document, load, render, dispatches };
|
||||
}
|
||||
function failure(config, status, data = { detail: 'Error' }) {
|
||||
return new realAxios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', config, null, { data, status, statusText: 'Failed', headers: {}, config });
|
||||
}
|
||||
function response(config, data, status = 200) { return { config, data, status, statusText: 'OK', headers: {} }; }
|
||||
|
||||
test('stores valid tokens and rejects malformed success responses', () => {
|
||||
const h = harness();
|
||||
h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
assert.equal(h.auth.getAccessToken(), 'access.old');
|
||||
assert.equal(h.auth.getRefreshToken(), 'refresh.old');
|
||||
assert.match(h.document.cookie, /isAuth=true/);
|
||||
assert.throws(() => h.auth.saveTokens({ access: undefined, refresh: 'bad' }));
|
||||
});
|
||||
test('request with no token never sends Bearer null or undefined', async () => {
|
||||
const h = harness();
|
||||
h.instance.defaults.adapter = config => { assert.equal(config.headers.Authorization, undefined); return Promise.resolve(response(config, {})); };
|
||||
await h.instance.get('/public/');
|
||||
});
|
||||
test('400, 403, 500 and network failures always reject and preserve session', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
for (const status of [400, 403, 500, null]) {
|
||||
h.instance.defaults.adapter = config => Promise.reject(status ? failure(config, status) : new realAxios.AxiosError('Offline', 'ERR_NETWORK', config));
|
||||
await assert.rejects(h.instance.get('/customer/v2/create/'));
|
||||
assert.equal(h.auth.getAccessToken(), 'access.old');
|
||||
assert.equal(h.redirects.length, 0);
|
||||
}
|
||||
});
|
||||
test('simultaneous 401 requests perform one refresh and both retry', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
let calls = 0;
|
||||
h.axios.defaults.adapter = async config => { calls++; await new Promise(resolve => setImmediate(resolve)); return response(config, { access: 'access.new', refresh: 'refresh.new' }); };
|
||||
h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, { ok: true }));
|
||||
const result = await Promise.all([h.instance.get('/first/'), h.instance.get('/second/')]);
|
||||
assert.equal(calls, 1); assert.equal(result.length, 2); assert.equal(h.auth.getAccessToken(), 'access.new');
|
||||
});
|
||||
test('revoked refresh clears tokens, cookie and redirects to login', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null);
|
||||
assert.equal(h.redirects[0], '/login?reason=session'); assert.match(h.document.cookie, /max-age=0/);
|
||||
});
|
||||
test('401 without refresh logs out and rejects', async () => {
|
||||
const h = harness();
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.redirects[0], '/login?reason=session');
|
||||
});
|
||||
test('network failure while refreshing preserves recoverable session', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => Promise.reject(new realAxios.AxiosError('Offline', 'ERR_NETWORK', config));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getRefreshToken(), 'refresh.old'); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('malformed refresh success logs out instead of storing undefined', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => Promise.resolve(response(config, { error: 'Invalid' }));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getAccessToken(), null);
|
||||
});
|
||||
test('retry 401 does not refresh forever', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0;
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); };
|
||||
await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), null);
|
||||
});
|
||||
test('public auth 401 displays error without triggering token refresh', async () => {
|
||||
const h = harness(); let calls = 0;
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, {})); };
|
||||
await assert.rejects(h.instance.post('/auth/token/', {}));
|
||||
assert.equal(calls, 0); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('logout revokes original refresh token and clears all local identity', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.local.set('nameFamily', 'Previous');
|
||||
h.axios.defaults.adapter = config => { assert.match(config.url, /auth\/logout/); assert.equal(JSON.parse(config.data).refresh, 'refresh.old'); return Promise.resolve(response(config, null, 204)); };
|
||||
await h.auth.logout(); assert.equal(h.local.size, 0); assert.equal(h.redirects[0], '/login');
|
||||
});
|
||||
test('late refresh response cannot restore a logged-out session', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let resolveRefresh;
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => new Promise(resolve => { resolveRefresh = () => resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); });
|
||||
const pending = h.instance.get('/cuser/info/');
|
||||
await new Promise(resolve => setImmediate(resolve)); h.auth.clearSession(); resolveRefresh();
|
||||
await assert.rejects(pending); assert.equal(h.auth.getAccessToken(), null);
|
||||
});
|
||||
test('API messages including duplicate mobile and SMS failure are preserved', () => {
|
||||
const h = harness();
|
||||
const config = {};
|
||||
assert.equal(h.auth.apiErrorMessage(failure(config, 400, { mobile: ['شماره تکراری است'] })), 'شماره تکراری است');
|
||||
assert.equal(h.auth.apiErrorMessage(failure(config, 503, { detail: 'ارسال پیامک انجام نشد' })), 'ارسال پیامک انجام نشد');
|
||||
});
|
||||
|
||||
function elements(tree, predicate) {
|
||||
if (!tree || typeof tree !== 'object') return [];
|
||||
if (Array.isArray(tree)) return tree.flatMap(item => elements(item, predicate));
|
||||
return [...(predicate(tree) ? [tree] : []), ...elements(tree.props?.children, predicate)];
|
||||
}
|
||||
function textContent(tree) {
|
||||
if (typeof tree === 'string' || typeof tree === 'number') return String(tree);
|
||||
if (Array.isArray(tree)) return tree.map(textContent).join('');
|
||||
return tree?.props ? textContent(tree.props.children) : '';
|
||||
}
|
||||
test('login page renders API errors and re-enables submit', async () => {
|
||||
const h = harness(); const component = h.load('src/app/(login_register)/login/page.tsx').default;
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 503, { detail: 'ارسال پیامک انجام نشد' }));
|
||||
const form = elements(h.render(component), item => item.type === 'form')[0];
|
||||
await form.props.onSubmit({ preventDefault() {} });
|
||||
const tree = h.render(component);
|
||||
const alerts = elements(tree, item => item.props?.role === 'alert');
|
||||
assert.ok(alerts.some(item => textContent(item).includes('ارسال پیامک انجام نشد')));
|
||||
assert.ok(elements(tree, item => item.type === 'button').some(item => !item.props.disabled));
|
||||
assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
|
||||
test('multi-role login renders role choices without automatically entering first workspace', async () => {
|
||||
const h = harness(); const component = h.load('src/app/(login_register)/login/page.tsx').default;
|
||||
h.axios.defaults.adapter = config => Promise.resolve(response(config, [1,2].map(id => ({ uuid: String(id), cuser_role: { sys_ws: { name: 'Workspace'+id }, role_type: { role_name: 'Operator' } } }))));
|
||||
await elements(h.render(component), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} });
|
||||
const tree = h.render(component);
|
||||
assert.equal(elements(tree, item => item.type === 'button' && item.props.type === 'button').length, 2);
|
||||
assert.equal(h.auth.getAccessToken(), null); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
|
||||
test('a stale retry failure does not clear a newer session from another tab', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => {
|
||||
if (config.headers.Authorization === 'Bearer access.new') h.auth.saveTokens({ access: 'access.other-tab', refresh: 'refresh.other-tab' });
|
||||
return Promise.reject(failure(config, 401));
|
||||
};
|
||||
h.axios.defaults.adapter = config => Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' }));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getAccessToken(), 'access.other-tab'); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||
const ts = require('typescript'), axios = require('axios');
|
||||
function harness() {
|
||||
const cache = {}, state = [], queries = new Map(), requests = [], invalidations = [], downloads = [];
|
||||
let cursor = 0;
|
||||
const local = new Map([['access', 'token']]);
|
||||
const window = { localStorage: { getItem: k => local.get(k) ?? null, setItem: (k, v) => local.set(k,v), removeItem: k => local.delete(k) }, sessionStorage: { removeItem() {} }, location: { pathname: '/products', replace() {} }, confirm: () => true };
|
||||
const document = { cookie: '', body: { appendChild() {} }, createElement: () => ({ click() { downloads.push(this.download); }, remove() {} }) };
|
||||
class FormDataStub { get [Symbol.toStringTag]() { return 'FormData'; } [Symbol.iterator]() { return this.values.entries(); } constructor(values = {}) { this.values = new Map(Object.entries(values)); } get(k) { return this.values.get(k) ?? null; } set(k,v) { this.values.set(k,v); } append(k,v) { this.values.set(k,v); } entries() { return this.values.entries(); } }
|
||||
const react = { ...require('react'), useState(initial) { const i = cursor++; if (!(i in state)) state[i] = initial; return [state[i], v => state[i] = typeof v === 'function' ? v(state[i]) : v]; } };
|
||||
const query = { useQuery: o => queries.get(o.queryKey[0]) || { isLoading: false, isError: false, refetch() {} }, useQueryClient: () => ({ invalidateQueries: async o => invalidations.push(o.queryKey[0]) }) };
|
||||
function load(filename) {
|
||||
const full = path.resolve(filename); if (cache[full]) return cache[full].exports;
|
||||
const module = { exports: {} }; cache[full] = module;
|
||||
const source = ts.transpileModule(fs.readFileSync(full, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||
const resolve = name => {
|
||||
if (name === 'axios') return axios;
|
||||
if (name === 'react') return react;
|
||||
if (name === '@tanstack/react-query') return query;
|
||||
if (name === 'react/jsx-runtime' || name.startsWith('react-icons/')) return require(name);
|
||||
if (name === 'next/link') return { __esModule: true, default: 'a' };
|
||||
const base = name.startsWith('@/') ? path.resolve('src', name.slice(2)) : path.resolve(path.dirname(full), name);
|
||||
return load(base + (fs.existsSync(base+'.ts') ? '.ts' : '.tsx'));
|
||||
};
|
||||
vm.runInNewContext('(function(require,module,exports){'+source+'\n})', { window, document, process, console, Error, BigInt, Set, Map, Blob, URL: { createObjectURL: () => 'blob:test', revokeObjectURL() {} }, FormData: FormDataStub, setTimeout: cb => cb() })(resolve, module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
const api = load('src/axios/axiosInstance.ts').default;
|
||||
api.defaults.adapter = async config => { requests.push(config); return { data: {}, status: 200, headers: {}, config }; };
|
||||
return { catalog: load('src/lib/catalog.ts'), api, load, requests, queries, state, invalidations, downloads, render: (component, props) => { cursor = 0; return component(props); } };
|
||||
}
|
||||
function all(tree) { if (tree == null || typeof tree === 'boolean') return []; if (Array.isArray(tree)) return tree.flatMap(all); if (typeof tree !== 'object') return [tree]; return [tree, ...all(tree.props?.children)]; }
|
||||
function text(tree) { return all(tree).filter(v => typeof v === 'string' || typeof v === 'number').join(' '); }
|
||||
function find(tree, condition) { const value = all(tree).find(v => v && typeof v === 'object' && condition(v)); assert.ok(value, 'Expected UI element'); return value; }
|
||||
const root = 'src/app/(index)/products/';
|
||||
const category = { id: 1, name: 'C', parent: null, order_cat: null, excel_order: null, image: null };
|
||||
const product = { id: 1, name: 'P', price: '100', category: 1, category_name: 'C', acc_code: 'P1', attr: null, view_only: false, order_prd: null, excel_order: null };
|
||||
|
||||
test('server capability and layout fail closed for missing permission, nonadmin and request failure', () => {
|
||||
const h = harness(), Layout = h.load(root+'layout.tsx').default;
|
||||
for (const info of [null, {}, { role: 'workspace_admin' }, { permissions: { manage_products: 'true' } }, { permissions: { manage_products: false } }]) {
|
||||
assert.equal(h.catalog.canManageProducts(info), false);
|
||||
h.queries.set('cuser-info', { data: { data: info } });
|
||||
assert.doesNotMatch(text(h.render(Layout, { children: 'PRIVATE_EDITOR' })), /PRIVATE_EDITOR/);
|
||||
}
|
||||
h.queries.set('cuser-info', { isError: true, error: new Error('Denied'), refetch() {} });
|
||||
assert.doesNotMatch(text(h.render(Layout, { children: 'PRIVATE_EDITOR' })), /PRIVATE_EDITOR/);
|
||||
h.queries.set('cuser-info', { data: { data: { permissions: { manage_products: true } } } });
|
||||
assert.match(text(h.render(Layout, { children: 'PRIVATE_EDITOR' })), /PRIVATE_EDITOR/);
|
||||
});
|
||||
test('numeric inputs preserve exact bigint price and accept Persian Arabic digits', () => {
|
||||
const h = harness();
|
||||
assert.equal(h.catalog.integerInput('۹٬۲۲۳', 'قیمت'), '9223');
|
||||
assert.equal(h.catalog.integerInput('١٢٣', 'ترتیب'), '123');
|
||||
assert.equal(h.catalog.integerInput('9223372036854775807', 'قیمت', false, '9223372036854775807'), '9223372036854775807');
|
||||
assert.equal(h.catalog.integerInput('', 'ترتیب', true), null);
|
||||
for (const value of ['-1','1.5','NaN','2147483648']) assert.throws(() => h.catalog.integerInput(value, 'ترتیب'));
|
||||
});
|
||||
test('category path renders ancestors and stops invalid cycles', () => {
|
||||
const h = harness(), parent = { ...category, name: 'Parent' }, child = { ...category, id: 2, parent: 1, name: 'Child' };
|
||||
assert.equal(h.catalog.categoryLabel(child, [parent, child]), 'Parent / Child');
|
||||
parent.parent = 2; assert.ok(h.catalog.categoryLabel(child, [parent, child]).length < 100);
|
||||
});
|
||||
test('API client uses protected routes, authenticated requests and exact bulk payload', async () => {
|
||||
const h = harness();
|
||||
await h.catalog.listProducts({ search: 'P', page: 2 });
|
||||
await h.catalog.saveProduct({ name: 'P', price: '1', category: 1 }, 7);
|
||||
await h.catalog.deleteProduct(8); await h.catalog.bulkProducts([7,8], 'move', { category: 2 });
|
||||
assert.equal(h.requests[0].url, 'shop/manage/products/'); assert.equal(h.requests[0].params.search, 'P');
|
||||
assert.equal(h.requests[1].method, 'patch'); assert.equal(h.requests[2].method, 'delete');
|
||||
assert.deepEqual(JSON.parse(h.requests[3].data).ids, [7,8]); assert.equal(h.requests[0].headers.Authorization, 'Bearer token');
|
||||
});
|
||||
test('product form displays duplicate code errors and allows retry', async () => {
|
||||
const h = harness(), Form = h.load(root+'components/product-form.tsx').default;
|
||||
h.api.defaults.adapter = async config => { throw new axios.AxiosError('Duplicate', 'ERR_BAD_REQUEST', config, null, { data: { acc_code: ['کد محصول تکراری است.'] }, status: 400, headers: {}, config }); };
|
||||
const props = { categories: [category], onSaved() { throw new Error('Should not succeed'); }, onCancel() {} };
|
||||
await h.render(Form, props).props.onSubmit({ preventDefault() {}, currentTarget: { name: 'P', price: '10', category: '1', acc_code: 'P1', attr: '', order_prd: '', excel_order: '' } });
|
||||
const tree = h.render(Form, props);
|
||||
assert.match(text(tree), /کد محصول تکراری/); assert.equal(find(tree, v => v.type === 'button' && text(v).includes('ذخیره')).props.disabled, false);
|
||||
});
|
||||
test('product form validates attributes and submits large price as string', async () => {
|
||||
const h = harness(), Form = h.load(root+'components/product-form.tsx').default;
|
||||
let saved = 0; const props = { categories: [category], onSaved: () => saved++, onCancel() {} };
|
||||
const data = { name: 'P', price: '9223372036854775807', category: '1', acc_code: 'P1', attr: '[]', order_prd: '', excel_order: '' };
|
||||
await h.render(Form, props).props.onSubmit({ preventDefault() {}, currentTarget: data });
|
||||
assert.equal(h.requests.length, 0); assert.match(text(h.render(Form, props)), /شیء JSON/);
|
||||
data.attr = '{}'; await h.render(Form, props).props.onSubmit({ preventDefault() {}, currentTarget: data });
|
||||
assert.equal(JSON.parse(h.requests[0].data).price, '9223372036854775807'); assert.equal(saved, 1);
|
||||
});
|
||||
test('products page applies filters and exposes editing controls', () => {
|
||||
const h = harness(), Page = h.load(root+'page.tsx').default;
|
||||
h.queries.set('managed-products', { data: { count: 1, results: [product], next: null } }); h.queries.set('managed-categories', { data: [category] });
|
||||
const tree = h.render(Page);
|
||||
find(tree, v => v.type === 'form').props.onSubmit({ preventDefault() {}, currentTarget: { search: 'P1', ordering: 'price', category: '1', view_only: '' } });
|
||||
assert.equal(h.state[0].search, 'P1'); assert.equal(h.state[1], 1);
|
||||
assert.match(text(tree), /افزودن محصول/); assert.match(text(tree), /ویرایش/); assert.match(text(tree), /حذف/);
|
||||
});
|
||||
test('category form sends parent and independently nullable ordering', async () => {
|
||||
const h = harness(), Page = h.load(root+'categories/page.tsx').default;
|
||||
h.queries.set('managed-categories', { data: [category] });
|
||||
let tree = h.render(Page); find(tree, v => v.type === 'button' && text(v).includes('افزودن دسته')).props.onClick();
|
||||
tree = h.render(Page);
|
||||
await find(tree, v => v.type === 'form').props.onSubmit({ preventDefault() {}, currentTarget: { name: 'Child', parent: '1', order_cat: '', excel_order: '۴', image: null } });
|
||||
const body = JSON.parse(h.requests[0].data); assert.equal(body.parent, 1); assert.equal(body.order_cat, null); assert.equal(body.excel_order, 4);
|
||||
assert.ok(h.invalidations.includes('managed-categories'));
|
||||
});
|
||||
test('Excel page displays per-row errors and blocks atomic confirmation', async () => {
|
||||
const h = harness(), Page = h.load(root+'import/page.tsx').default;
|
||||
const batch = { id: 3, filename: 'file.xlsx', created: 1, updated: 0, errors: 1, committed: false, can_confirm: false, expires_at: '2099-01-01', row_count: 2, row_page: 1, options: { errors: 'atomic' }, rows: [{ row: 3, values: { name: 'Bad', acc_code: 'BAD', category: 'C' }, status: 'error', errors: { price: ['قیمت نامعتبر است.'] } }] };
|
||||
h.api.defaults.adapter = async config => { h.requests.push(config); return { data: batch, status: 201, headers: {}, config }; };
|
||||
let tree = h.render(Page);
|
||||
await find(tree, v => v.type === 'form').props.onSubmit({ preventDefault() {}, currentTarget: { file: 'xlsx', mode: 'upsert', errors: 'atomic', create_categories: 'on', mapping: '', ordering: 'preserve' } });
|
||||
tree = h.render(Page); assert.match(text(tree), /قیمت نامعتبر/);
|
||||
assert.equal(find(tree, v => v.type === 'button' && text(v).includes('تأیید و ثبت نهایی')).props.disabled, true);
|
||||
assert.equal(h.requests[0].url, 'shop/manage/import/preview/'); assert.equal(h.requests[0].timeout, 120000);
|
||||
assert.ok(h.invalidations.includes('product-imports'));
|
||||
});
|
||||
test('download errors retain API message and do not download a false success file', async () => {
|
||||
const h = harness();
|
||||
h.api.defaults.adapter = async config => { throw new axios.AxiosError('Forbidden', 'ERR_BAD_RESPONSE', config, null, { status: 403, data: new Blob([JSON.stringify({ detail: 'دسترسی ندارید.' })]), headers: {}, config }); };
|
||||
await assert.rejects(h.catalog.downloadCatalog('products'), /دسترسی ندارید/); assert.equal(h.downloads.length, 0);
|
||||
});
|
||||
|
||||
test('sidebar only offers product management for explicit admin capability', () => {
|
||||
const h = harness(), Sidebar = h.load('src/app/(index)/components/side-right.tsx').default;
|
||||
h.queries.set('cuser-info', { data: { data: { permissions: { manage_products: false } } } });
|
||||
assert.doesNotMatch(text(h.render(Sidebar)), /مدیریت محصولات/);
|
||||
h.queries.set('cuser-info', { data: { data: { permissions: { manage_products: true } } } });
|
||||
assert.match(text(h.render(Sidebar)), /مدیریت محصولات/);
|
||||
});
|
||||
Reference in new issue
Block a user