This commit is contained in:
javad committed 2026-10-08 20:23:15 +03:30
1 parent be18543b04
commit aa3872fbb1
20 files changed
+2079 -230

No files matched your search

+4
View File
@@ -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
+2 -12
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useRouter } from 'next/navigation' import { logout } from '@/lib/auth'
import axiosInstance from '@/axios/axiosInstance' import axiosInstance from '@/axios/axiosInstance'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import Link from 'next/link' import Link from 'next/link'
@@ -8,7 +8,6 @@ import { useEffect, useState } from 'react'
const NavTop = ()=>{ const NavTop = ()=>{
const router = useRouter();
const [nameFamily, setNameFamily] = useState<string | null>(null) const [nameFamily, setNameFamily] = useState<string | null>(null)
useEffect( ()=>{ useEffect( ()=>{
@@ -26,16 +25,7 @@ const NavTop = ()=>{
},}) },})
const onLogout = ()=>{ const onLogout = () => { void logout() }
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)
}
return ( 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 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> <div></div>
+9 -3
View File
@@ -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 Link from "next/link";
import { AiFillAppstore, AiFillHome } from "react-icons/ai"; import { AiFillAppstore, AiFillHome } from "react-icons/ai";
import { HiShoppingCart } from "react-icons/hi"; import { HiShoppingCart } from "react-icons/hi";
@@ -11,6 +15,7 @@ import { IoNotifications } from "react-icons/io5";
const SideRight = ()=>{ const SideRight = ()=>{
const info = useQuery({ queryKey: ['cuser-info'], queryFn: () => api.get('cuser/info/'), retry: false })
const menuLinks = [ const menuLinks = [
{name:'خانه', link: '/', size:25, icon: <AiFillHome size={25} color={'white'} />}, {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: <FaMoneyCheckDollar size={25} color={'white'} />},
{name:'واریزی ها', link: '', size:25, icon: <FaHandHoldingDollar size={25} color={'white'} />}, {name:'واریزی ها', link: '', size:25, icon: <FaHandHoldingDollar size={25} color={'white'} />},
{name:'پایگاه دانش', link: '', size:25, icon: <BsFillQuestionCircleFill size={25} color={'white'} />}, {name:'پایگاه دانش', link: '', size:25, icon: <BsFillQuestionCircleFill size={25} color={'white'} />},
] ]
return ( return (
@@ -34,14 +39,15 @@ const SideRight = ()=>{
<div className="flex flex-col relative pr-2 pl-2"> <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"> <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> <p className="font-light text-sm mt-2 ">پنل کارشناسان</p>
</div> </div>
<div className=""> <div className="">
<ul> <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)=>{ {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"> <li className="bg-slate-600 p-2 flex flex-row gap-x-2 mb-4 rounded-md">
{item.icon} {item.icon}
+1 -1
View File
@@ -49,7 +49,7 @@ const customerPageID = ({params}:{params: {id: number} }) => {
const [summary, setSummary] = useState<ISummary | null>(null) 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/`) const summary = await axiosInstance.get(`customer/${params.id}/summary/`)
return { summary: summary,} return { summary: summary,}
@@ -1,5 +1,8 @@
'use client' 'use client'
import { apiErrorMessage } from '@/lib/auth'
import { useQueryClient } from '@tanstack/react-query'
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { FaRegEdit } from "react-icons/fa"; import { FaRegEdit } from "react-icons/fa";
@@ -59,8 +62,8 @@ interface IProps {
} }
interface IShahr { interface IShahr {
id: Number, id: number,
name: String name: string
is_ostan: boolean, is_ostan: boolean,
"ostan": any "ostan": any
} }
@@ -77,6 +80,8 @@ interface ICustomerForm {
} }
const CustomerInfo = (props: IProps)=>{ const CustomerInfo = (props: IProps)=>{
const queryClient = useQueryClient()
const [submitError, setSubmitError] = useState('')
const [edit, setEdit] = useState<boolean>(false) const [edit, setEdit] = useState<boolean>(false)
const [shahr, setShahr] = useState<IShahr[] | []>([]) const [shahr, setShahr] = useState<IShahr[] | []>([])
@@ -121,6 +126,7 @@ const CustomerInfo = (props: IProps)=>{
}) })
const onSubmit: SubmitHandler<ICustomerForm> = async (data) => { const onSubmit: SubmitHandler<ICustomerForm> = async (data) => {
setSubmitError('')
try { try {
@@ -128,6 +134,7 @@ const CustomerInfo = (props: IProps)=>{
const result = await axiosInstance.patch(`/customer/update/${props.customer.id}/`, data) const result = await axiosInstance.patch(`/customer/update/${props.customer.id}/`, data)
if(result.status == 200){ if(result.status == 200){
await queryClient.invalidateQueries({ queryKey: ['customer-summary'] })
setEdit(false) setEdit(false)
setTimeout(()=>{ setTimeout(()=>{
alert("با موفقیت ویرایش شد") alert("با موفقیت ویرایش شد")
@@ -138,7 +145,7 @@ const CustomerInfo = (props: IProps)=>{
} }
} catch (error) { } catch (error) {
console.log(error) setSubmitError(apiErrorMessage(error))
} }
} }
@@ -188,6 +195,7 @@ const CustomerInfo = (props: IProps)=>{
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-3 p-4"> <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"> <div className="name_family">
@@ -285,7 +293,7 @@ const CustomerInfo = (props: IProps)=>{
</div> </div>
{edit ? <Button className="bg-blue-900 text-white rounded-md">ویرایش</Button> : null} {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> </form>
@@ -1,5 +1,8 @@
'use client' 'use client'
import { apiErrorMessage } from '@/lib/auth'
import { useQueryClient } from '@tanstack/react-query'
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { FaRegEdit } from "react-icons/fa"; import { FaRegEdit } from "react-icons/fa";
@@ -73,10 +76,12 @@ interface IShahr {
} }
interface IForm { interface IForm {
status: boolean status: string
} }
const CustomerOnlinePanel = (props: IProps)=>{ const CustomerOnlinePanel = (props: IProps)=>{
const queryClient = useQueryClient()
const [submitError, setSubmitError] = useState('')
const [edit, setEdit] = useState<boolean>(false) const [edit, setEdit] = useState<boolean>(false)
const [shahr, setShahr] = useState<IShahr[] | []>([]) const [shahr, setShahr] = useState<IShahr[] | []>([])
@@ -96,6 +101,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
}) })
const onSubmit: SubmitHandler<IForm> = async (data) => { const onSubmit: SubmitHandler<IForm> = async (data) => {
setSubmitError('')
try { try {
@@ -103,10 +109,11 @@ const CustomerOnlinePanel = (props: IProps)=>{
const result = await axiosInstance.post(`/customer/customer-panel/`, { const result = await axiosInstance.post(`/customer/customer-panel/`, {
customer_id: props.customer.id, customer_id: props.customer.id,
mobile: props.customer.mobile, mobile: props.customer.mobile,
status: data.status status: String(data.status) === 'true'
}) })
if(result.status == 200){ if(result.status == 200){
await queryClient.invalidateQueries({ queryKey: ['customer-summary'] })
setEdit(false) setEdit(false)
setTimeout(()=>{ setTimeout(()=>{
alert("با موفقیت ثبت شد") alert("با موفقیت ثبت شد")
@@ -116,7 +123,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
} }
} catch (error) { } catch (error) {
console.log(error) setSubmitError(apiErrorMessage(error))
} }
} }
@@ -124,9 +131,9 @@ const CustomerOnlinePanel = (props: IProps)=>{
useEffect(()=>{ useEffect(()=>{
if(props.customer?.cuser_role?.customer_panel){ 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{ }else{
setValue('status', false) setValue('status', 'false')
} }
@@ -146,6 +153,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-3 p-4"> <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"> <div className="shive-mantaghe flex flex-col gap-x-3">
@@ -175,7 +183,7 @@ const CustomerOnlinePanel = (props: IProps)=>{
</div> </div>
{edit ? <Button className="bg-blue-900 text-white rounded-md">ویرایش</Button> : null} {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> </form>
+8 -3
View File
@@ -1,5 +1,7 @@
'use client' 'use client'
import { apiErrorMessage } from '@/lib/auth'
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import CustomerNewHeader from "../components/customer-new-header" import CustomerNewHeader from "../components/customer-new-header"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@@ -64,6 +66,7 @@ interface ICustomerForm {
const newCustomerPage = ()=>{ const newCustomerPage = ()=>{
const [submitError, setSubmitError] = useState('')
const [formItems, setFormItems] = useState<IFormItems | null>(null) const [formItems, setFormItems] = useState<IFormItems | null>(null)
const [shahr, setShahr] = useState<IShahr[] | null>(null) const [shahr, setShahr] = useState<IShahr[] | null>(null)
const router = useRouter() const router = useRouter()
@@ -79,6 +82,7 @@ const newCustomerPage = ()=>{
}) })
const onSubmit: SubmitHandler<ICustomerForm> = async (data) => { const onSubmit: SubmitHandler<ICustomerForm> = async (data) => {
setSubmitError('')
try { try {
const result = await axiosInstance.post('/customer/v2/create/', data) const result = await axiosInstance.post('/customer/v2/create/', data)
@@ -90,7 +94,7 @@ const newCustomerPage = ()=>{
} }
} catch (error) { } catch (error) {
console.log(error) setSubmitError(apiErrorMessage(error))
} }
} }
@@ -101,7 +105,7 @@ const newCustomerPage = ()=>{
return item.ostan?.id == e.target.value 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"> <div className="flex flex-col gap-y-2 p-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col w-full"> <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 className="title grid grid-cols-4 gap-x-8 gap-y-4">
<div> <div>
@@ -216,7 +221,7 @@ const newCustomerPage = ()=>{
<AlertTitle>خطا</AlertTitle> <AlertTitle>خطا</AlertTitle>
<AlertDescription> <AlertDescription>
{Object.keys(errors).map((item, key)=>{ {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> </AlertDescription>
</Alert> </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>
}
+60
View File
@@ -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>
}
+18
View File
@@ -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>
}
+71
View File
@@ -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>
}
+50 -110
View File
@@ -1,120 +1,60 @@
'use client' 'use client'
import { useRef, useState } from 'react' import { FormEvent, useEffect, useState } from 'react'
import axiosInstance from '@/axios/axiosInstance'
import axios from 'axios' import axios from 'axios'
import { log } from 'util'; import { apiErrorMessage, LoginOption, obtainTokens } from '@/lib/auth'
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)
}
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"> async function login(event: FormEvent) {
<label>نام کاربری</label> event.preventDefault()
<input ref={username} className="border-[1px] text-center rounded h-9" type="text" /> setBusy(true)
<label>کلمه عبور</label> setError('')
<input ref={password} className="border-[1px] text-center rounded h-9" type="password" /> setOptions([])
{loginButton ? ( try {
<button onClick={loginUser} className="button border-[1px] rounded-md mt-5 bg-slate-800 h-9 text-white" >ثبت</button> const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/username/', { username, password }, { timeout: 10000 })
) : ( const roles: LoginOption[] = response.data
<p className='text-center text-red-800'>درحال ورود به سیستم لطفا صبر کنید</p> if (!Array.isArray(roles) || !roles.length) throw new Error('دسترسی فعالی برای ورود وجود ندارد.')
)} if (roles.length === 1) await choose(roles[0])
</div> else setOptions(roles)
} catch (failure) { setError(apiErrorMessage(failure)) }
</div> finally { setBusy(false) }
}
{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>
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>
</div>
</>
)
} }
export default LoginPage
+53 -67
View File
@@ -1,78 +1,64 @@
'use client' 'use client'
import axios from 'axios' import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { error } from 'console'; import { expireSession, getAccessToken, getRefreshToken, saveTokens } from '@/lib/auth'
import { cookies, headers } from 'next/headers'
//let accessToken = localStorage.getItem('access') ? localStorage.getItem('access') : null
type RetryRequest = InternalAxiosRequestConfig & { _retry?: boolean }
class InvalidSession extends Error {}
let pendingRefresh: Promise<string> | null = null
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_API_URL}/`, baseURL: process.env.NEXT_PUBLIC_API_URL + '/',
timeout: 10000, timeout: 10000,
headers: { headers: { 'Content-Type': 'application/json' },
'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
}) })
axiosInstance.interceptors.response.use((response)=> { axiosInstance.interceptors.request.use(request => {
return response const access = getAccessToken()
}, async (error) =>{ if (access) request.headers.Authorization = 'Bearer ' + access
else delete request.headers.Authorization
return request
})
const originalRequest = error.config; async function refreshAccess(): Promise<string> {
const refresh = getRefreshToken()
if (!refresh) throw new InvalidSession()
if (error.response.status === 401 && !originalRequest._retry) { 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()
originalRequest._retry = true; // Prevent infinite loops saveTokens(response.data)
return response.data.access
try { } catch (error) {
if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession()
const rf_tkn:string | any = await localStorage.getItem('refresh') throw error
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;
}
} }
}); }
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
+76
View File
@@ -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)
}
+78
View File
@@ -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
View File
@@ -1,30 +1,13 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
if (request.cookies.get('isAuth')?.value !== 'true') {
let isAuth:any = request.cookies.get('isAuth') return NextResponse.redirect(new URL('/login', request.url))
}
try { return NextResponse.next()
if(isAuth.value != "true"){
return NextResponse.redirect(new URL('/login', request.url))
}
} catch {
return NextResponse.redirect(new URL('/login', request.url))
}
} }
export const config = { export const config = {
matcher: [ matcher: ['/((?!api|_next/static|_next/image|favicon.ico|login|register).*)'],
/* }
* 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).*)',
],
}
+185
View File
@@ -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);
});
+131
View File
@@ -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)), /مدیریت محصولات/);
});