feat(users-admin): implement user management features including list, detail, and form pages

This commit is contained in:
hossein taromi 2025-08-31 11:48:14 +03:30
parent 014b3d3f48
commit 9544517fc9
16 changed files with 1963 additions and 190 deletions

View File

@ -22,6 +22,9 @@ describe("Smoke Tests", () => {
cy.visit("/orders"); cy.visit("/orders");
cy.url().should("include", "/orders"); cy.url().should("include", "/orders");
cy.visit("/users-admin");
cy.url().should("include", "/users-admin");
cy.visit("/admin-users"); cy.visit("/admin-users");
cy.url().should("include", "/admin-users"); cy.url().should("include", "/admin-users");

View File

@ -0,0 +1,349 @@
/// <reference types="../support" />
describe("Users Admin Management", () => {
beforeEach(() => {
cy.login();
cy.visit("/users-admin");
cy.waitForLoading();
});
it("should display users admin list page", () => {
cy.contains("مدیریت کاربران").should("be.visible");
cy.getByTestId("create-user-button").should("be.visible");
});
it("should navigate to create user page", () => {
cy.getByTestId("create-user-button").click();
cy.url().should("include", "/users-admin/create");
cy.contains("ایجاد کاربر جدید").should("be.visible");
});
it("should create a new user", () => {
cy.getByTestId("create-user-button").click();
// Fill basic information
cy.getByTestId("first-name-input").type("محمد");
cy.getByTestId("last-name-input").type("احمدی");
cy.getByTestId("phone-number-input").type("09123456789");
cy.getByTestId("email-input").type("mohammad.ahmadi@example.com");
cy.getByTestId("national-code-input").type("1234567890");
cy.getByTestId("password-input").type("password123");
// Set verification status
cy.getByTestId("verified-true-radio").check();
// Submit form
cy.getByTestId("submit-button").click();
// Verify creation
cy.url().should("include", "/users-admin/");
cy.url().should("not.include", "/create");
});
it("should validate required fields", () => {
cy.getByTestId("create-user-button").click();
// Submit button should be disabled initially
cy.getByTestId("submit-button").should("be.disabled");
// Fill only first name
cy.getByTestId("first-name-input").type("محمد");
cy.getByTestId("submit-button").should("be.disabled");
// Fill all required fields
cy.getByTestId("last-name-input").type("احمدی");
cy.getByTestId("phone-number-input").type("09123456789");
// Now submit button should be enabled
cy.getByTestId("submit-button").should("not.be.disabled");
});
it("should validate phone number format", () => {
cy.getByTestId("create-user-button").click();
// Test invalid phone number
cy.getByTestId("phone-number-input").type("123456");
cy.getByTestId("first-name-input").type("محمد");
cy.getByTestId("last-name-input").type("احمدی");
cy.get(".text-red-600").should("contain", "شماره تلفن معتبر نیست");
// Fix phone number
cy.getByTestId("phone-number-input").clear().type("09123456789");
cy.get(".text-red-600").should("not.contain", "شماره تلفن معتبر نیست");
});
it("should validate email format", () => {
cy.getByTestId("create-user-button").click();
// Test invalid email
cy.getByTestId("email-input").type("invalid-email");
cy.getByTestId("first-name-input").type("محمد");
cy.get(".text-red-600").should("contain", "ایمیل معتبر نیست");
// Fix email
cy.getByTestId("email-input").clear().type("valid@example.com");
cy.get(".text-red-600").should("not.contain", "ایمیل معتبر نیست");
});
it("should search users", () => {
// Search by text
cy.getByTestId("search-users-input").type("محمد");
cy.getByTestId("search-button").click();
cy.wait(500);
// Clear search
cy.getByTestId("clear-filters-button").click();
cy.getByTestId("search-users-input").should("have.value", "");
});
it("should filter users by status", () => {
// Filter by verified status
cy.getByTestId("status-filter-select").select("verified");
cy.getByTestId("search-button").click();
cy.wait(500);
// Filter by unverified status
cy.getByTestId("status-filter-select").select("unverified");
cy.getByTestId("search-button").click();
cy.wait(500);
// Reset filter
cy.getByTestId("status-filter-select").select("all");
cy.getByTestId("search-button").click();
});
it("should handle user verification toggle", () => {
// Mock API response for users list
cy.intercept("GET", "**/users**", {
statusCode: 200,
body: {
users: [
{
id: 1,
phone_number: "+989123456789",
first_name: "محمد",
last_name: "احمدی",
email: "mohammad@example.com",
verified: false,
},
],
total: 1,
limit: 20,
offset: 0,
},
}).as("getUsers");
// Mock verify API
cy.intercept("POST", "**/users/1/verify", {
statusCode: 200,
body: { message: "User verified successfully" },
}).as("verifyUser");
cy.visit("/users-admin");
cy.wait("@getUsers");
// Click verify button
cy.getByTestId("verify-user-1").click();
cy.wait("@verifyUser");
// Check for success message
cy.contains("کاربر با موفقیت تأیید شد").should("be.visible");
});
it("should view user details", () => {
// Mock API response
cy.intercept("GET", "**/users**", {
statusCode: 200,
body: {
users: [
{
id: 1,
phone_number: "+989123456789",
first_name: "محمد",
last_name: "احمدی",
email: "mohammad@example.com",
verified: true,
},
],
},
}).as("getUsers");
cy.intercept("GET", "**/users/1", {
statusCode: 200,
body: {
id: 1,
phone_number: "+989123456789",
first_name: "محمد",
last_name: "احمدی",
email: "mohammad@example.com",
verified: true,
},
}).as("getUser");
cy.visit("/users-admin");
cy.wait("@getUsers");
// Click view button
cy.getByTestId("view-user-1").click();
cy.wait("@getUser");
cy.url().should("include", "/users-admin/1");
cy.contains("جزئیات کاربر").should("be.visible");
cy.contains("محمد احمدی").should("be.visible");
});
it("should edit user", () => {
// Mock get user API
cy.intercept("GET", "**/users/1", {
statusCode: 200,
body: {
id: 1,
phone_number: "+989123456789",
first_name: "محمد",
last_name: "احمدی",
email: "mohammad@example.com",
verified: true,
},
}).as("getUser");
// Mock update user API
cy.intercept("PUT", "**/users/1", {
statusCode: 200,
body: {
id: 1,
phone_number: "+989123456789",
first_name: "محمد",
last_name: "احمدی ویرایش شده",
email: "mohammad.updated@example.com",
verified: true,
},
}).as("updateUser");
cy.visit("/users-admin/1/edit");
cy.wait("@getUser");
// Edit user information
cy.getByTestId("last-name-input").clear().type("احمدی ویرایش شده");
cy.getByTestId("email-input").clear().type("mohammad.updated@example.com");
// Submit form
cy.getByTestId("submit-button").click();
cy.wait("@updateUser");
// Check for success message
cy.contains("کاربر با موفقیت به‌روزرسانی شد").should("be.visible");
});
it("should delete user with confirmation", () => {
// Mock API responses
cy.intercept("GET", "**/users**", {
statusCode: 200,
body: {
users: [
{
id: 1,
phone_number: "+989123456789",
first_name: "محمد",
last_name: "احمدی",
email: "mohammad@example.com",
verified: true,
},
],
},
}).as("getUsers");
cy.intercept("DELETE", "**/users/1", {
statusCode: 200,
body: { message: "User deleted successfully" },
}).as("deleteUser");
cy.visit("/users-admin");
cy.wait("@getUsers");
// Click delete button
cy.getByTestId("delete-user-1").click();
// Confirm deletion in modal
cy.contains("آیا از حذف کاربر").should("be.visible");
cy.contains("button", "حذف").click();
cy.wait("@deleteUser");
// Check for success message
cy.contains("کاربر با موفقیت حذف شد").should("be.visible");
});
it("should handle form cancellation", () => {
cy.getByTestId("create-user-button").click();
// Fill some data
cy.getByTestId("first-name-input").type("محمد");
cy.getByTestId("last-name-input").type("احمدی");
// Click cancel
cy.getByTestId("cancel-button").click();
// Should return to list page
cy.url().should("include", "/users-admin");
cy.url().should("not.include", "/create");
});
it("should show empty state when no users found", () => {
// Mock empty users response
cy.intercept("GET", "**/users**", {
statusCode: 200,
body: {
users: [],
total: 0,
limit: 20,
offset: 0,
},
}).as("getEmptyUsers");
cy.visit("/users-admin");
cy.wait("@getEmptyUsers");
cy.contains("هیچ کاربری یافت نشد").should("be.visible");
cy.contains("برای شروع یک کاربر ایجاد کنید").should("be.visible");
});
it("should work on mobile viewport", () => {
cy.viewport("iphone-6");
cy.getByTestId("create-user-button").should("be.visible");
cy.getByTestId("create-user-button").click();
cy.contains("ایجاد کاربر جدید").should("be.visible");
// Form should be usable on mobile
cy.getByTestId("first-name-input").type("محمد");
cy.getByTestId("last-name-input").type("احمدی");
cy.getByTestId("phone-number-input").type("09123456789");
cy.getByTestId("submit-button").should("be.visible");
});
it("should be accessible", () => {
cy.getByTestId("create-user-button").click();
// Check for proper labels
cy.get("label").should("have.length.greaterThan", 5);
// Check for required field indicators
cy.getByTestId("first-name-input").should(
"have.attr",
"aria-required",
"true"
);
cy.getByTestId("last-name-input").should(
"have.attr",
"aria-required",
"true"
);
// Check for proper form structure
cy.get("form").should("exist");
cy.get(".bg-gradient-to-r").should("have.length.greaterThan", 1);
});
});

View File

@ -50,6 +50,11 @@ const DiscountCodeFormPage = lazy(() => import('./pages/discount-codes/discount-
const OrdersListPage = lazy(() => import('./pages/orders/orders-list/OrdersListPage')); const OrdersListPage = lazy(() => import('./pages/orders/orders-list/OrdersListPage'));
const OrderDetailPage = lazy(() => import('./pages/orders/order-detail/OrderDetailPage')); const OrderDetailPage = lazy(() => import('./pages/orders/order-detail/OrderDetailPage'));
// Users Admin Pages
const UsersAdminListPage = lazy(() => import('./pages/users-admin/users-admin-list/UsersAdminListPage'));
const UserAdminDetailPage = lazy(() => import('./pages/users-admin/user-admin-detail/UserAdminDetailPage'));
const UserAdminFormPage = lazy(() => import('./pages/users-admin/user-admin-form/UserAdminFormPage'));
// Products Pages // Products Pages
const ProductsListPage = lazy(() => import('./pages/products/products-list/ProductsListPage')); const ProductsListPage = lazy(() => import('./pages/products/products-list/ProductsListPage'));
const ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage')); const ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage'));
@ -124,6 +129,12 @@ const AppRoutes = () => {
<Route path="orders" element={<OrdersListPage />} /> <Route path="orders" element={<OrdersListPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} /> <Route path="orders/:id" element={<OrderDetailPage />} />
{/* Users Admin Routes */}
<Route path="users-admin" element={<UsersAdminListPage />} />
<Route path="users-admin/create" element={<UserAdminFormPage />} />
<Route path="users-admin/:id" element={<UserAdminDetailPage />} />
<Route path="users-admin/:id/edit" element={<UserAdminFormPage />} />
{/* Landing Hero Route */} {/* Landing Hero Route */}
<Route path="landing-hero" element={<HeroSliderPage />} /> <Route path="landing-hero" element={<HeroSliderPage />} />

View File

@ -14,6 +14,7 @@ import {
Sliders, Sliders,
BadgePercent, BadgePercent,
ShoppingCart, ShoppingCart,
Users,
X X
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@ -39,6 +40,16 @@ const menuItems: MenuItem[] = [
icon: ShoppingCart, icon: ShoppingCart,
path: '/orders', path: '/orders',
}, },
{
title: 'مدیریت کاربران',
icon: Users,
path: '/users-admin',
},
{
title: 'کدهای تخفیف',
icon: BadgePercent,
path: '/discount-codes',
},
{ {
title: 'مدیریت محصولات', title: 'مدیریت محصولات',
icon: Package, icon: Package,
@ -58,11 +69,6 @@ const menuItems: MenuItem[] = [
icon: Sliders, icon: Sliders,
path: '/product-options', path: '/product-options',
}, },
{
title: 'کدهای تخفیف',
icon: BadgePercent,
path: '/discount-codes',
},
] ]
}, },
{ {

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { Label } from './Typography'; import { Label } from './Typography';
import { persianToEnglish } from '../../utils/numberUtils'; import { persianToEnglish, formatWithThousands } from '../../utils/numberUtils';
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> { interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string; label?: string;
@ -10,10 +10,11 @@ interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, '
inputSize?: 'sm' | 'md' | 'lg'; inputSize?: 'sm' | 'md' | 'lg';
icon?: React.ComponentType<{ className?: string }>; icon?: React.ComponentType<{ className?: string }>;
numeric?: boolean; numeric?: boolean;
thousandSeparator?: boolean;
} }
export const Input = React.forwardRef<HTMLInputElement, InputProps>( export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, inputSize = 'md', className, id, onChange, type, numeric, ...props }, ref) => { ({ label, error, helperText, inputSize = 'md', className, id, onChange, type, numeric, thousandSeparator, ...props }, ref) => {
const sizeClasses = { const sizeClasses = {
sm: 'px-3 py-2 text-sm', sm: 'px-3 py-2 text-sm',
md: 'px-3 py-3 text-base', md: 'px-3 py-3 text-base',
@ -32,9 +33,24 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
); );
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if ((type === 'number' || numeric) && e.target.value) { let value = e.target.value;
const convertedValue = persianToEnglish(e.target.value); if ((type === 'number' || numeric) && value) {
e.target.value = convertedValue; value = persianToEnglish(value);
}
if (thousandSeparator) {
const caret = e.target.selectionStart || 0;
const prevLength = e.target.value.length;
value = formatWithThousands(value);
e.target.value = value;
const newLength = value.length;
const delta = newLength - prevLength;
requestAnimationFrame(() => {
try {
e.target.setSelectionRange(caret + delta, caret + delta);
} catch { }
});
} else {
e.target.value = value;
} }
onChange?.(e); onChange?.(e);
}; };
@ -49,7 +65,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
const inputProps = { const inputProps = {
ref, ref,
id, id,
type: numeric ? 'text' : type, type: numeric || thousandSeparator ? 'text' : type,
inputMode: getInputMode(), inputMode: getInputMode(),
className: inputClasses, className: inputClasses,
onChange: handleChange, onChange: handleChange,

View File

@ -92,4 +92,16 @@ export const API_ROUTES = {
GET_ORDERS: "checkout/orders", GET_ORDERS: "checkout/orders",
GET_ORDER: (id: string) => `checkout/orders/${id}`, GET_ORDER: (id: string) => `checkout/orders/${id}`,
UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`, UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`,
// User Admin APIs
GET_USERS: "api/v1/admin/users",
GET_USER: (id: string) => `api/v1/admin/users/${id}`,
SEARCH_USERS: "api/v1/admin/users/search",
CREATE_USER: "api/v1/admin/users",
UPDATE_USER: (id: string) => `api/v1/admin/users/${id}`,
UPDATE_USER_PROFILE: (id: string) => `api/v1/admin/users/${id}/profile`,
UPDATE_USER_AVATAR: (id: string) => `api/v1/admin/users/${id}/avatar`,
DELETE_USER: (id: string) => `api/v1/admin/users/${id}`,
VERIFY_USER: (id: string) => `api/v1/admin/users/${id}/verify`,
UNVERIFY_USER: (id: string) => `api/v1/admin/users/${id}/unverify`,
}; };

View File

@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup'; import * as yup from 'yup';
import { parseFormattedNumber } from '@/utils/numberUtils';
import { useDiscountCode, useCreateDiscountCode, useUpdateDiscountCode } from '../core/_hooks'; import { useDiscountCode, useCreateDiscountCode, useUpdateDiscountCode } from '../core/_hooks';
import { CreateDiscountCodeRequest } from '../core/_models'; import { CreateDiscountCodeRequest } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@ -19,8 +20,16 @@ const schema = yup.object({
value: yup.number().typeError('مقدار نامعتبر است').required('مقدار الزامی است').min(0.01, 'مقدار باید بیشتر از صفر باشد'), value: yup.number().typeError('مقدار نامعتبر است').required('مقدار الزامی است').min(0.01, 'مقدار باید بیشتر از صفر باشد'),
status: yup.mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']).required('وضعیت الزامی است'), status: yup.mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']).required('وضعیت الزامی است'),
application_level: yup.mixed<'invoice' | 'category' | 'product' | 'shipping' | 'product_fee'>().oneOf(['invoice', 'category', 'product', 'shipping', 'product_fee']).required('سطح اعمال الزامی است'), application_level: yup.mixed<'invoice' | 'category' | 'product' | 'shipping' | 'product_fee'>().oneOf(['invoice', 'category', 'product', 'shipping', 'product_fee']).required('سطح اعمال الزامی است'),
min_purchase_amount: yup.number().transform((v, o) => o === '' ? undefined : v).min(0.01, 'مبلغ باید بیشتر از صفر باشد').nullable(), min_purchase_amount: yup
max_discount_amount: yup.number().transform((v, o) => o === '' ? undefined : v).min(0.01, 'مبلغ باید بیشتر از صفر باشد').nullable(), .number()
.transform((val, original) => parseFormattedNumber(original) as any)
.min(0.01, 'مبلغ باید بیشتر از صفر باشد')
.nullable(),
max_discount_amount: yup
.number()
.transform((val, original) => parseFormattedNumber(original) as any)
.min(0.01, 'مبلغ باید بیشتر از صفر باشد')
.nullable(),
usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).min(1, 'حداقل ۱ بار استفاده').nullable(), usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).min(1, 'حداقل ۱ بار استفاده').nullable(),
user_usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).min(1, 'حداقل ۱ بار استفاده').nullable(), user_usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).min(1, 'حداقل ۱ بار استفاده').nullable(),
single_use: yup.boolean().required('این فیلد الزامی است'), single_use: yup.boolean().required('این فیلد الزامی است'),
@ -227,6 +236,8 @@ const DiscountCodeFormPage = () => {
step="0.01" step="0.01"
placeholder="100000" placeholder="100000"
error={errors.min_purchase_amount?.message as string} error={errors.min_purchase_amount?.message as string}
thousandSeparator
numeric
{...register('min_purchase_amount')} {...register('min_purchase_amount')}
/> />
<Input <Input
@ -235,6 +246,8 @@ const DiscountCodeFormPage = () => {
step="0.01" step="0.01"
placeholder="50000" placeholder="50000"
error={errors.max_discount_amount?.message as string} error={errors.max_discount_amount?.message as string}
thousandSeparator
numeric
{...register('max_discount_amount')} {...register('max_discount_amount')}
/> />
</div> </div>

View File

@ -1,43 +1,13 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks'; import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks';
import { DiscountCode } from '../core/_models'; import { DiscountCode } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types";
import { Percent, BadgePercent, Trash2, Edit3, Plus, Ticket } from 'lucide-react'; import { Percent, BadgePercent, Trash2, Edit3, Plus, Ticket } from 'lucide-react';
const ListSkeleton = () => (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نوع</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">بازه زمانی</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i}>
{Array.from({ length: 6 }).map((__, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
const DiscountCodesListPage = () => { const DiscountCodesListPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
@ -55,6 +25,61 @@ const DiscountCodesListPage = () => {
} }
}; };
const columns: TableColumn[] = useMemo(() => [
{ key: 'code', label: 'کد', sortable: true },
{ key: 'name', label: 'نام', sortable: true },
{
key: 'value',
label: 'مقدار',
render: (_val, row: any) => row.type === 'percentage' ? `${row.value}%` : `${row.value} تومان`
},
{
key: 'application_level',
label: 'سطح',
render: (_val, row: any) => row.application_level === 'invoice' ? 'کل سبد' : row.application_level === 'category' ? 'دسته‌بندی' : row.application_level === 'product' ? 'محصول' : 'ارسال'
},
{
key: 'status',
label: 'وضعیت',
render: (val: string) => (
<span className={`px-2 py-1 rounded-full text-xs ${val === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
{val === 'active' ? 'فعال' : 'غیرفعال'}
</span>
)
},
{
key: 'period',
label: 'بازه زمانی',
render: (_val, row: any) => (
<span>
{row.valid_from ? new Date(row.valid_from).toLocaleDateString('fa-IR') : '-'} تا {row.valid_to ? new Date(row.valid_to).toLocaleDateString('fa-IR') : '-'}
</span>
)
},
{
key: 'actions',
label: 'عملیات',
render: (_val, row: any) => (
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(row.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="ویرایش"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteId(row.id.toString())}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
}
], [navigate]);
if (error) { if (error) {
return ( return (
<div className="p-6"> <div className="p-6">
@ -101,7 +126,7 @@ const DiscountCodesListPage = () => {
</div> </div>
{isLoading ? ( {isLoading ? (
<ListSkeleton /> <Table columns={columns} data={discountCodes as any[] || []} loading={true} />
) : !discountCodes || discountCodes.length === 0 ? ( ) : !discountCodes || discountCodes.length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="text-center py-12"> <div className="text-center py-12">
@ -115,69 +140,7 @@ const DiscountCodesListPage = () => {
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <Table columns={columns} data={discountCodes as any[]} />
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">مقدار</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">سطح</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">بازه زمانی</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{(discountCodes || []).map((dc: DiscountCode) => (
<tr key={dc.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{dc.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{dc.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{dc.type === 'percentage' ? `${dc.value}%` : `${dc.value} تومان`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{dc.application_level === 'invoice' ? 'کل سبد' :
dc.application_level === 'category' ? 'دسته‌بندی' :
dc.application_level === 'product' ? 'محصول' : 'ارسال'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<span className={`px-2 py-1 rounded-full text-xs ${dc.status === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
{dc.status === 'active' ? 'فعال' : 'غیرفعال'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{dc.valid_from ? new Date(dc.valid_from).toLocaleDateString('fa-IR') : '-'}
{' '}تا{' '}
{dc.valid_to ? new Date(dc.valid_to).toLocaleDateString('fa-IR') : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(dc.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="ویرایش"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteId(dc.id.toString())}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)} )}
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف کد تخفیف"> <Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف کد تخفیف">

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks'; import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks';
import { Order, OrderFilters, OrderStatus } from '../core/_models'; import { Order, OrderFilters, OrderStatus } from '../core/_models';
@ -7,6 +7,8 @@ import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography"; import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types";
import { import {
ShoppingCart, ShoppingCart,
Package, Package,
@ -53,23 +55,7 @@ const formatDate = (dateString: string) => {
}; };
const ListSkeleton = () => ( const ListSkeleton = () => (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <Table columns={[]} data={[]} loading={true} />
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i}>
{Array.from({ length: 7 }).map((__, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
); );
const OrdersListPage = () => { const OrdersListPage = () => {
@ -87,6 +73,45 @@ const OrdersListPage = () => {
const { data: stats, isLoading: statsLoading } = useOrderStats(); const { data: stats, isLoading: statsLoading } = useOrderStats();
const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus(); const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus();
const columns: TableColumn[] = useMemo(() => [
{ key: 'order_number', label: 'شماره سفارش', sortable: true, render: (v: string) => `#${v}` },
{
key: 'customer',
label: 'مشتری',
render: (_val, row: any) => (
<div>
<div className="font-medium">{row.customer.first_name} {row.customer.last_name}</div>
<div className="text-gray-500 dark:text-gray-400">{row.customer.email}</div>
</div>
)
},
{ key: 'total_amount', label: 'مبلغ', sortable: true, render: (v: number) => formatCurrency(v) },
{ key: 'status', label: 'وضعیت', render: (v: OrderStatus) => (<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(v)}`}>{getStatusText(v)}</span>) },
{ key: 'created_at', label: 'تاریخ', sortable: true, render: (v: string) => formatDate(v) },
{
key: 'actions',
label: 'عملیات',
render: (_val, row: any) => (
<div className="flex items-center gap-2">
<button
onClick={() => handleViewOrder(row.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="مشاهده جزئیات"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => handleUpdateStatus(row.id, row.status)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
title="تغییر وضعیت"
>
<Edit3 className="h-4 w-4" />
</button>
</div>
)
},
], []);
const handleStatusUpdate = () => { const handleStatusUpdate = () => {
if (statusUpdateId) { if (statusUpdateId) {
updateStatus( updateStatus(
@ -246,68 +271,7 @@ const OrdersListPage = () => {
</div> </div>
) : ( ) : (
<> <>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <Table columns={columns} data={ordersData.orders as any[]} />
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">شماره سفارش</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">مشتری</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">مبلغ</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">تاریخ</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{ordersData.orders.map((order: Order) => (
<tr key={order.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
#{order.order_number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div className="font-medium">{order.customer.first_name} {order.customer.last_name}</div>
<div className="text-gray-500 dark:text-gray-400">{order.customer.email}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(order.total_amount)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
{getStatusText(order.status)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(order.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleViewOrder(order.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="مشاهده جزئیات"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => handleUpdateStatus(order.id, order.status)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
title="تغییر وضعیت"
>
<Edit3 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* صفحه‌بندی */}
<Pagination <Pagination
currentPage={filters.page || 1} currentPage={filters.page || 1}
totalPages={Math.ceil((ordersData.total || 0) / (filters.limit || 20))} totalPages={Math.ceil((ordersData.total || 0) / (filters.limit || 20))}

View File

@ -0,0 +1,227 @@
// User Admin React Query hooks
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { QUERY_KEYS as QUERY_KEY } from "../../../utils/query-key";
import {
getUsers,
getUser,
searchUsers,
createUser,
updateUser,
updateUserProfile,
updateUserAvatar,
deleteUser,
verifyUser,
unverifyUser,
getUserStats,
} from "./_requests";
import {
User,
UserFilters,
CreateUserRequest,
UpdateUserRequest,
UpdateUserProfileRequest,
UpdateUserAvatarRequest,
PaginatedUsersResponse,
} from "./_models";
// Get all users
export const useUsers = (filters?: UserFilters) => {
return useQuery({
queryKey: [QUERY_KEY.GET_USERS, filters],
queryFn: () => getUsers(filters),
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
// Get user by ID
export const useUser = (id: string) => {
return useQuery({
queryKey: [QUERY_KEY.GET_USER, id],
queryFn: () => getUser(id),
enabled: !!id,
staleTime: 5 * 60 * 1000,
});
};
// Search users with filters
export const useSearchUsers = (filters: UserFilters) => {
return useQuery({
queryKey: [QUERY_KEY.SEARCH_USERS, filters],
queryFn: () => searchUsers(filters),
enabled: Object.keys(filters).length > 0,
staleTime: 2 * 60 * 1000, // 2 minutes for search results
});
};
// Get user statistics
export const useUserStats = () => {
return useQuery({
queryKey: [QUERY_KEY.USER_STATS],
queryFn: getUserStats,
staleTime: 10 * 60 * 1000, // 10 minutes
});
};
// Create user mutation
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData: CreateUserRequest) => createUser(userData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.USER_STATS] });
toast.success("کاربر با موفقیت ایجاد شد");
},
onError: (error: any) => {
const message = error?.response?.data?.message || "خطا در ایجاد کاربر";
toast.error(message);
},
});
};
// Update user mutation
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
userData,
}: {
id: string;
userData: UpdateUserRequest;
}) => updateUser(id, userData),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.GET_USER, variables.id],
});
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.USER_STATS] });
toast.success("کاربر با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
const message =
error?.response?.data?.message || "خطا در به‌روزرسانی کاربر";
toast.error(message);
},
});
};
// Update user profile mutation
export const useUpdateUserProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
userData,
}: {
id: string;
userData: UpdateUserProfileRequest;
}) => updateUserProfile(id, userData),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.GET_USER, variables.id],
});
toast.success("پروفایل کاربر با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
const message =
error?.response?.data?.message || "خطا در به‌روزرسانی پروفایل";
toast.error(message);
},
});
};
// Update user avatar mutation
export const useUpdateUserAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
avatarData,
}: {
id: string;
avatarData: UpdateUserAvatarRequest;
}) => updateUserAvatar(id, avatarData),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.GET_USER, variables.id],
});
toast.success("تصویر کاربر با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
const message =
error?.response?.data?.message || "خطا در به‌روزرسانی تصویر";
toast.error(message);
},
});
};
// Delete user mutation
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteUser(id),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.removeQueries({ queryKey: [QUERY_KEY.GET_USER, variables] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.USER_STATS] });
toast.success("کاربر با موفقیت حذف شد");
},
onError: (error: any) => {
const message = error?.response?.data?.message || "خطا در حذف کاربر";
toast.error(message);
},
});
};
// Verify user mutation
export const useVerifyUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => verifyUser(id),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.GET_USER, variables],
});
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.USER_STATS] });
toast.success("کاربر با موفقیت تأیید شد");
},
onError: (error: any) => {
const message = error?.response?.data?.message || "خطا در تأیید کاربر";
toast.error(message);
},
});
};
// Unverify user mutation
export const useUnverifyUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => unverifyUser(id),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.GET_USERS] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.GET_USER, variables],
});
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.USER_STATS] });
toast.success("تأیید کاربر لغو شد");
},
onError: (error: any) => {
const message =
error?.response?.data?.message || "خطا در لغو تأیید کاربر";
toast.error(message);
},
});
};

View File

@ -0,0 +1,155 @@
// User Admin API requests
import {
httpGetRequest,
httpPostRequest,
httpPutRequest,
httpDeleteRequest,
} from "../../../utils/baseHttpService";
import { APIUrlGenerator } from "../../../utils/baseHttpService";
import { API_ROUTES } from "../../../constant/routes";
import {
User,
PaginatedUsersResponse,
UserFilters,
CreateUserRequest,
UpdateUserRequest,
UpdateUserProfileRequest,
UpdateUserAvatarRequest,
UserActionResponse,
UserStats,
} from "./_models";
// Get all users with pagination
export const getUsers = async (filters?: UserFilters): Promise<User[]> => {
const queryParams: Record<string, string | number | boolean | null> = {};
if (filters?.limit) queryParams.limit = filters.limit;
if (filters?.offset) queryParams.offset = filters.offset;
const response = await httpGetRequest<PaginatedUsersResponse>(
APIUrlGenerator(API_ROUTES.GET_USERS, queryParams)
);
return response.data.users || [];
};
// Get user by ID
export const getUser = async (id: string): Promise<User> => {
const response = await httpGetRequest<User>(API_ROUTES.GET_USER(id));
return response.data;
};
// Search users with filters
export const searchUsers = async (
filters: UserFilters
): Promise<PaginatedUsersResponse> => {
const queryParams: Record<string, string | number | boolean | null> = {};
if (filters.verified !== undefined) queryParams.verified = filters.verified;
if (filters.search_text) queryParams.search_text = filters.search_text;
if (filters.phone_number) queryParams.phone_number = filters.phone_number;
if (filters.email) queryParams.email = filters.email;
if (filters.national_code) queryParams.national_code = filters.national_code;
if (filters.limit) queryParams.limit = filters.limit;
if (filters.offset) queryParams.offset = filters.offset;
const response = await httpGetRequest<PaginatedUsersResponse>(
APIUrlGenerator(API_ROUTES.SEARCH_USERS, queryParams)
);
return response.data;
};
// Create new user
export const createUser = async (
userData: CreateUserRequest
): Promise<User> => {
const response = await httpPostRequest<User>(
API_ROUTES.CREATE_USER,
userData
);
return response.data;
};
// Update user
export const updateUser = async (
id: string,
userData: UpdateUserRequest
): Promise<User> => {
const response = await httpPutRequest<User>(
API_ROUTES.UPDATE_USER(id),
userData
);
return response.data;
};
// Update user profile
export const updateUserProfile = async (
id: string,
userData: UpdateUserProfileRequest
): Promise<User> => {
const response = await httpPutRequest<User>(
API_ROUTES.UPDATE_USER_PROFILE(id),
userData
);
return response.data;
};
// Update user avatar
export const updateUserAvatar = async (
id: string,
avatarData: UpdateUserAvatarRequest
): Promise<UserActionResponse> => {
const response = await httpPutRequest<UserActionResponse>(
API_ROUTES.UPDATE_USER_AVATAR(id),
avatarData
);
return response.data;
};
// Delete user
export const deleteUser = async (id: string): Promise<UserActionResponse> => {
const response = await httpDeleteRequest<UserActionResponse>(
API_ROUTES.DELETE_USER(id)
);
return response.data;
};
// Verify user
export const verifyUser = async (id: string): Promise<UserActionResponse> => {
const response = await httpPostRequest<UserActionResponse>(
API_ROUTES.VERIFY_USER(id),
{}
);
return response.data;
};
// Unverify user
export const unverifyUser = async (id: string): Promise<UserActionResponse> => {
const response = await httpPostRequest<UserActionResponse>(
API_ROUTES.UNVERIFY_USER(id),
{}
);
return response.data;
};
// Get user statistics
export const getUserStats = async (): Promise<UserStats> => {
const allUsers = await getUsers({ limit: 1000 });
const stats: UserStats = {
total_users: allUsers.length,
verified_users: allUsers.filter((user) => user.verified).length,
unverified_users: allUsers.filter((user) => !user.verified).length,
recent_registrations: allUsers.filter((user) => {
if (!user.created_at) return false;
const createdDate = new Date(user.created_at);
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
return createdDate > thirtyDaysAgo;
}).length,
};
return stats;
};

View File

@ -0,0 +1,342 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { User, Edit, UserCheck, UserX, Trash2, ArrowLeft, Phone, Mail, CreditCard, Calendar } from 'lucide-react';
import { useUser, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { PageContainer } from '../../../components/ui/Typography';
import { Button } from '../../../components/ui/Button';
import { Modal } from '../../../components/ui/Modal';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
const UserAdminDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [deleteModal, setDeleteModal] = useState(false);
// Hooks
const { data: user, isLoading, error } = useUser(id!);
const verifyUserMutation = useVerifyUser();
const unverifyUserMutation = useUnverifyUser();
const deleteUserMutation = useDeleteUser();
// Handlers
const handleEdit = () => {
navigate(`/users-admin/${id}/edit`);
};
const handleVerifyToggle = () => {
if (!user) return;
if (user.verified) {
unverifyUserMutation.mutate(user.id.toString());
} else {
verifyUserMutation.mutate(user.id.toString());
}
};
const handleDeleteClick = () => {
setDeleteModal(true);
};
const handleDeleteConfirm = () => {
if (user) {
deleteUserMutation.mutate(user.id.toString(), {
onSuccess: () => {
navigate('/users-admin');
}
});
}
};
const handleBack = () => {
navigate('/users-admin');
};
if (isLoading) {
return (
<PageContainer>
<div className="flex justify-center items-center py-12">
<LoadingSpinner />
</div>
</PageContainer>
);
}
if (error || !user) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-red-600">خطا در بارگذاری اطلاعات کاربر</p>
<Button onClick={handleBack} className="mt-4">
بازگشت به لیست کاربران
</Button>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="secondary"
onClick={handleBack}
className="flex items-center gap-2"
data-testid="back-button"
>
<ArrowLeft className="h-4 w-4" />
بازگشت
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
جزئیات کاربر
</h1>
<p className="text-gray-600 dark:text-gray-400">
مشاهده و مدیریت اطلاعات کاربر
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleEdit}
className="flex items-center gap-2"
data-testid="edit-user-button"
>
<Edit className="h-4 w-4" />
ویرایش
</Button>
<Button
variant={user.verified ? "secondary" : "primary"}
onClick={handleVerifyToggle}
loading={verifyUserMutation.isPending || unverifyUserMutation.isPending}
className="flex items-center gap-2"
data-testid="verify-toggle-button"
>
{user.verified ? (
<>
<UserX className="h-4 w-4" />
لغو تأیید
</>
) : (
<>
<UserCheck className="h-4 w-4" />
تأیید کاربر
</>
)}
</Button>
<Button
variant="danger"
onClick={handleDeleteClick}
className="flex items-center gap-2"
data-testid="delete-user-button"
>
<Trash2 className="h-4 w-4" />
حذف
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* User Profile Card */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<div className="text-center">
<div className="mx-auto h-24 w-24 mb-4">
{user.avatar ? (
<img
className="h-24 w-24 rounded-full object-cover mx-auto"
src={user.avatar}
alt={`${user.first_name} ${user.last_name}`}
/>
) : (
<div className="h-24 w-24 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center mx-auto">
<span className="text-2xl font-bold text-gray-700 dark:text-gray-300">
{user.first_name.charAt(0)}{user.last_name.charAt(0)}
</span>
</div>
)}
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1">
{user.first_name} {user.last_name}
</h2>
<div className="flex justify-center mb-4">
<span
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${user.verified
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}
>
{user.verified ? 'تأیید شده' : 'تأیید نشده'}
</span>
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm">
شناسه کاربر: {user.id}
</p>
</div>
</div>
</div>
{/* User Details */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<User className="h-5 w-5" />
اطلاعات شخصی
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Phone className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">شماره تلفن</p>
<p className="text-gray-900 dark:text-gray-100">{user.phone_number}</p>
</div>
</div>
{user.email && (
<div className="flex items-center gap-3">
<Mail className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">ایمیل</p>
<p className="text-gray-900 dark:text-gray-100">{user.email}</p>
</div>
</div>
)}
{user.national_code && (
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">کد ملی</p>
<p className="text-gray-900 dark:text-gray-100">{user.national_code}</p>
</div>
</div>
)}
</div>
<div className="space-y-4">
{user.created_at && (
<div className="flex items-center gap-3">
<Calendar className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">تاریخ ثبتنام</p>
<p className="text-gray-900 dark:text-gray-100">
{new Date(user.created_at).toLocaleDateString('fa-IR')}
</p>
</div>
</div>
)}
{user.updated_at && (
<div className="flex items-center gap-3">
<Calendar className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">آخرین بهروزرسانی</p>
<p className="text-gray-900 dark:text-gray-100">
{new Date(user.updated_at).toLocaleDateString('fa-IR')}
</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<UserCheck className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">وضعیت حساب</p>
<p className={`font-medium ${user.verified
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}`}>
{user.verified ? 'حساب تأیید شده است' : 'حساب تأیید نشده است'}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Actions Section */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
عملیات سریع
</h3>
<div className="flex flex-wrap gap-3">
<Button
onClick={handleEdit}
className="flex items-center gap-2"
data-testid="quick-edit-button"
>
<Edit className="h-4 w-4" />
ویرایش اطلاعات
</Button>
<Button
variant={user.verified ? "secondary" : "primary"}
onClick={handleVerifyToggle}
loading={verifyUserMutation.isPending || unverifyUserMutation.isPending}
className="flex items-center gap-2"
data-testid="quick-verify-button"
>
{user.verified ? (
<>
<UserX className="h-4 w-4" />
لغو تأیید حساب
</>
) : (
<>
<UserCheck className="h-4 w-4" />
تأیید حساب کاربر
</>
)}
</Button>
</div>
</div>
{/* Delete Confirmation Modal */}
<Modal
isOpen={deleteModal}
onClose={() => setDeleteModal(false)}
title="حذف کاربر"
>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">
آیا از حذف کاربر "{user.first_name} {user.last_name}" اطمینان دارید؟
</p>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-sm text-red-600 dark:text-red-400">
<strong>هشدار:</strong> این عمل غیرقابل بازگشت است و تمام اطلاعات کاربر به طور کامل حذف خواهد شد.
</p>
</div>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setDeleteModal(false)}
data-testid="cancel-delete-button"
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={deleteUserMutation.isPending}
data-testid="confirm-delete-button"
>
حذف کاربر
</Button>
</div>
</div>
</Modal>
</div>
</PageContainer>
);
};
export default UserAdminDetailPage;

View File

@ -0,0 +1,322 @@
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { User, ArrowLeft, Save, UserPlus } from 'lucide-react';
import { useUser, useCreateUser, useUpdateUser } from '../core/_hooks';
import { CreateUserRequest, UpdateUserRequest } from '../core/_models';
import { PageContainer } from '../../../components/ui/Typography';
import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
// Validation schema
const createUserSchema = yup.object({
phone_number: yup
.string()
.required('شماره تلفن الزامی است')
.matches(/^(\+98|0)?9\d{9}$/, 'شماره تلفن معتبر نیست'),
first_name: yup
.string()
.required('نام الزامی است')
.min(1, 'نام باید حداقل ۱ کاراکتر باشد')
.max(100, 'نام نباید بیشتر از ۱۰۰ کاراکتر باشد'),
last_name: yup
.string()
.required('نام خانوادگی الزامی است')
.min(1, 'نام خانوادگی باید حداقل ۱ کاراکتر باشد')
.max(100, 'نام خانوادگی نباید بیشتر از ۱۰۰ کاراکتر باشد'),
email: yup
.string()
.email('ایمیل معتبر نیست')
.max(255, 'ایمیل نباید بیشتر از ۲۵۵ کاراکتر باشد')
.nullable(),
national_code: yup
.string()
.max(20, 'کد ملی نباید بیشتر از ۲۰ کاراکتر باشد')
.nullable(),
verified: yup.boolean().required('وضعیت تأیید الزامی است'),
password: yup
.string()
.min(6, 'رمز عبور باید حداقل ۶ کاراکتر باشد')
.max(100, 'رمز عبور نباید بیشتر از ۱۰۰ کاراکتر باشد')
.nullable(),
});
const updateUserSchema = yup.object({
first_name: yup
.string()
.required('نام الزامی است')
.min(1, 'نام باید حداقل ۱ کاراکتر باشد')
.max(100, 'نام نباید بیشتر از ۱۰۰ کاراکتر باشد'),
last_name: yup
.string()
.required('نام خانوادگی الزامی است')
.min(1, 'نام خانوادگی باید حداقل ۱ کاراکتر باشد')
.max(100, 'نام خانوادگی نباید بیشتر از ۱۰۰ کاراکتر باشد'),
email: yup
.string()
.email('ایمیل معتبر نیست')
.max(255, 'ایمیل نباید بیشتر از ۲۵۵ کاراکتر باشد')
.nullable(),
national_code: yup
.string()
.max(20, 'کد ملی نباید بیشتر از ۲۰ کاراکتر باشد')
.nullable(),
verified: yup.boolean().required('وضعیت تأیید الزامی است'),
});
type FormData = CreateUserRequest | UpdateUserRequest;
const UserAdminFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEdit = !!id;
// Hooks
const { data: user, isLoading: userLoading } = useUser(id!, { enabled: isEdit });
const createUserMutation = useCreateUser();
const updateUserMutation = useUpdateUser();
const {
register,
handleSubmit,
formState: { errors, isValid },
reset,
setValue,
} = useForm<FormData>({
resolver: yupResolver(isEdit ? updateUserSchema : createUserSchema),
mode: 'onChange',
defaultValues: {
verified: false,
},
});
// Populate form in edit mode
useEffect(() => {
if (isEdit && user) {
setValue('first_name', user.first_name);
setValue('last_name', user.last_name);
setValue('email', user.email || '');
setValue('national_code', user.national_code || '');
setValue('verified', user.verified);
}
}, [isEdit, user, setValue]);
// Handlers
const onSubmit = (data: FormData) => {
if (isEdit && id) {
updateUserMutation.mutate(
{ id, userData: data as UpdateUserRequest },
{
onSuccess: () => {
navigate(`/users-admin/${id}`);
},
}
);
} else {
createUserMutation.mutate(data as CreateUserRequest, {
onSuccess: (newUser) => {
navigate(`/users-admin/${newUser.id}`);
},
});
}
};
const handleBack = () => {
if (isEdit) {
navigate(`/users-admin/${id}`);
} else {
navigate('/users-admin');
}
};
const isLoading = createUserMutation.isPending || updateUserMutation.isPending;
if (isEdit && userLoading) {
return (
<PageContainer>
<div className="flex justify-center items-center py-12">
<LoadingSpinner />
</div>
</PageContainer>
);
}
return (
<PageContainer>
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="secondary"
onClick={handleBack}
className="flex items-center gap-2"
data-testid="back-button"
>
<ArrowLeft className="h-4 w-4" />
بازگشت
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
{isEdit ? <User className="h-6 w-6" /> : <UserPlus className="h-6 w-6" />}
{isEdit ? 'ویرایش کاربر' : 'ایجاد کاربر جدید'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isEdit ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید به سیستم'}
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* اطلاعات اصلی */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-4 rounded-t-xl">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<User className="h-5 w-5" />
اطلاعات اصلی کاربر
</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Input
label="نام"
type="text"
placeholder="مثال: محمد"
error={errors.first_name?.message}
{...register('first_name')}
data-testid="first-name-input"
required
aria-required="true"
/>
<Input
label="نام خانوادگی"
type="text"
placeholder="مثال: احمدی"
error={errors.last_name?.message}
{...register('last_name')}
data-testid="last-name-input"
required
aria-required="true"
/>
{!isEdit && (
<Input
label="شماره تلفن"
type="tel"
placeholder="مثال: 09123456789"
error={errors.phone_number?.message}
{...register('phone_number')}
data-testid="phone-number-input"
required
aria-required="true"
/>
)}
<Input
label="ایمیل"
type="email"
placeholder="مثال: user@example.com"
error={errors.email?.message}
{...register('email')}
data-testid="email-input"
/>
<Input
label="کد ملی"
type="text"
placeholder="مثال: 1234567890"
error={errors.national_code?.message}
{...register('national_code')}
data-testid="national-code-input"
/>
{!isEdit && (
<Input
label="رمز عبور"
type="password"
placeholder="حداقل ۶ کاراکتر"
error={errors.password?.message}
{...register('password')}
data-testid="password-input"
/>
)}
</div>
</div>
</div>
{/* تنظیمات حساب */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="bg-gradient-to-r from-green-600 to-green-700 px-6 py-4 rounded-t-xl">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<User className="h-5 w-5" />
تنظیمات حساب
</h2>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
وضعیت تأیید
</label>
<div className="flex items-center gap-4">
<label className="flex items-center">
<input
type="radio"
value="true"
{...register('verified')}
data-testid="verified-true-radio"
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 dark:border-gray-600"
/>
<span className="mr-2 text-sm text-gray-900 dark:text-gray-100">تأیید شده</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="false"
{...register('verified')}
data-testid="verified-false-radio"
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 dark:border-gray-600"
/>
<span className="mr-2 text-sm text-gray-900 dark:text-gray-100">تأیید نشده</span>
</label>
</div>
{errors.verified && (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{errors.verified.message}
</p>
)}
</div>
</div>
</div>
</div>
{/* دکمه‌های اکشن */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex flex-col sm:flex-row gap-3 justify-end">
<Button
type="button"
variant="secondary"
onClick={handleBack}
className="sm:order-1"
data-testid="cancel-button"
>
انصراف
</Button>
<Button
type="submit"
variant="primary"
loading={isLoading}
disabled={!isValid}
className="sm:order-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800"
data-testid="submit-button"
>
<Save className="h-4 w-4 ml-2" />
{isEdit ? 'به‌روزرسانی کاربر' : 'ایجاد کاربر'}
</Button>
</div>
</div>
</form>
</div>
</PageContainer>
);
};
export default UserAdminFormPage;

View File

@ -0,0 +1,358 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Plus, Search, Filter, UserCheck, UserX, Edit, Trash2, Eye } from 'lucide-react';
import { useUsers, useUserStats, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { User, UserFilters } from '../core/_models';
import { PageContainer } from '../../../components/ui/Typography';
import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input';
import { Modal } from '../../../components/ui/Modal';
import { Pagination } from '../../../components/ui/Pagination';
import { StatsCard } from '../../../components/dashboard/StatsCard';
import { Table } from '../../../components/ui/Table';
import { TableColumn } from '../../../types';
const UsersAdminListPage: React.FC = () => {
const navigate = useNavigate();
// State for filters and pagination
const [filters, setFilters] = useState<UserFilters>({
limit: 20,
offset: 0
});
const [searchTerm, setSearchTerm] = useState('');
const [selectedStatus, setSelectedStatus] = useState<'all' | 'verified' | 'unverified'>('all');
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user: User | null }>({
isOpen: false,
user: null
});
// Hooks
const { data: users = [], isLoading, error } = useUsers(filters);
const { data: stats } = useUserStats();
const verifyUserMutation = useVerifyUser();
const unverifyUserMutation = useUnverifyUser();
const deleteUserMutation = useDeleteUser();
// Handlers
const handleCreate = () => {
navigate('/users-admin/create');
};
const handleEdit = (user: User) => {
navigate(`/users-admin/${user.id}/edit`);
};
const handleView = (user: User) => {
navigate(`/users-admin/${user.id}`);
};
const handleSearch = () => {
const newFilters: UserFilters = {
...filters,
offset: 0,
};
if (searchTerm.trim()) {
newFilters.search_text = searchTerm.trim();
} else {
delete newFilters.search_text;
}
if (selectedStatus !== 'all') {
newFilters.verified = selectedStatus === 'verified';
} else {
delete newFilters.verified;
}
setFilters(newFilters);
};
const handleClearFilters = () => {
setSearchTerm('');
setSelectedStatus('all');
setFilters({
limit: 20,
offset: 0
});
};
const handleVerifyToggle = (user: User) => {
if (user.verified) {
unverifyUserMutation.mutate(user.id.toString());
} else {
verifyUserMutation.mutate(user.id.toString());
}
};
const handleDeleteClick = (user: User) => {
setDeleteModal({ isOpen: true, user });
};
const handleDeleteConfirm = () => {
if (deleteModal.user) {
deleteUserMutation.mutate(deleteModal.user.id.toString());
setDeleteModal({ isOpen: false, user: null });
}
};
const handlePageChange = (newOffset: number) => {
setFilters(prev => ({ ...prev, offset: newOffset }));
};
const columns: TableColumn[] = useMemo(() => [
{
key: 'name',
label: 'کاربر',
render: (_val, row: any) => (
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
{row.avatar ? (
<img className="h-10 w-10 rounded-full object-cover" src={row.avatar} alt={`${row.first_name} ${row.last_name}`} />
) : (
<div className="h-10 w-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{row.first_name?.charAt(0)}{row.last_name?.charAt(0)}
</span>
</div>
)}
</div>
<div className="mr-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{row.first_name} {row.last_name}
</div>
{row.national_code && (
<div className="text-sm text-gray-500 dark:text-gray-400">کد ملی: {row.national_code}</div>
)}
</div>
</div>
)
},
{ key: 'phone_number', label: 'شماره تلفن' },
{ key: 'email', label: 'ایمیل', render: (v: string) => v || '-' },
{
key: 'verified',
label: 'وضعیت',
render: (v: boolean) => (
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${v
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}>
{v ? 'تأیید شده' : 'تأیید نشده'}
</span>
)
},
{
key: 'actions',
label: 'عملیات',
render: (_val, row: any) => (
<div className="flex items-center gap-2">
<button
onClick={() => handleView(row)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
title="مشاهده جزئیات"
data-testid={`view-user-${row.id}`}
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => handleEdit(row)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="ویرایش"
data-testid={`edit-user-${row.id}`}
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleVerifyToggle(row)}
className={`${row.verified
? 'text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300'
: 'text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300'
}`}
title={row.verified ? 'لغو تأیید' : 'تأیید کاربر'}
data-testid={`verify-user-${row.id}`}
>
{row.verified ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />}
</button>
<button
onClick={() => handleDeleteClick(row)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="حذف"
data-testid={`delete-user-${row.id}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
}
], [handleEdit, handleView, handleVerifyToggle]);
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-red-600">خطا در بارگذاری کاربران</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Users className="h-6 w-6" />
مدیریت کاربران
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">مشاهده و مدیریت کاربران سیستم</p>
</div>
<Button
onClick={handleCreate}
className="flex items-center gap-2"
data-testid="create-user-button"
>
<Plus className="h-4 w-4" />
کاربر جدید
</Button>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatsCard
title="کل کاربران"
value={stats.total_users}
icon={Users}
color="blue"
/>
<StatsCard
title="کاربران تأیید شده"
value={stats.verified_users}
icon={UserCheck}
color="green"
/>
<StatsCard
title="کاربران تأیید نشده"
value={stats.unverified_users}
icon={UserX}
color="yellow"
/>
<StatsCard
title="ثبت‌نام‌های اخیر"
value={stats.recent_registrations}
icon={Plus}
color="purple"
/>
</div>
)}
{/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
placeholder="جستجو بر اساس نام، شماره تلفن یا ایمیل..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
data-testid="search-users-input"
/>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as any)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100"
data-testid="status-filter-select"
>
<option value="all">همه کاربران</option>
<option value="verified">تأیید شده</option>
<option value="unverified">تأیید نشده</option>
</select>
<div className="flex gap-2">
<Button
onClick={handleSearch}
className="flex items-center gap-2"
data-testid="search-button"
>
<Search className="h-4 w-4" />
جستجو
</Button>
<Button
variant="secondary"
onClick={handleClearFilters}
className="flex items-center gap-2"
data-testid="clear-filters-button"
>
<Filter className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</div>
</div>
{/* Users Table */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{isLoading ? (
<Table columns={columns} data={[]} loading={true} />
) : users.length === 0 ? (
<div className="text-center py-12">
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
هیچ کاربری یافت نشد
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
برای شروع یک کاربر ایجاد کنید
</p>
<Button onClick={handleCreate}>ایجاد کاربر جدید</Button>
</div>
) : (
<Table columns={columns} data={users as any[]} />
)}
</div>
{/* Pagination */}
{users.length > 0 && (
<Pagination
currentPage={Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1}
totalPages={Math.ceil((stats?.total_users || 0) / (filters.limit || 20))}
onPageChange={(page) => handlePageChange((page - 1) * (filters.limit || 20))}
/>
)}
{/* Delete Confirmation Modal */}
<Modal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, user: null })}
title="حذف کاربر"
>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">
آیا از حذف کاربر "{deleteModal.user?.first_name} {deleteModal.user?.last_name}" اطمینان دارید؟
</p>
<p className="text-sm text-red-600 dark:text-red-400">
این عمل غیرقابل بازگشت است.
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setDeleteModal({ isOpen: false, user: null })}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={deleteUserMutation.isPending}
>
حذف
</Button>
</div>
</div>
</Modal>
</div>
</PageContainer>
);
};
export default UsersAdminListPage;

View File

@ -83,3 +83,22 @@ export const createOptionalNumberTransform = () => {
return isNaN(num) ? null : num; return isNaN(num) ? null : num;
}; };
}; };
export const formatWithThousands = (value: string | number): string => {
if (value === null || value === undefined) return "";
const str = persianToEnglish(value.toString());
if (str === "") return "";
const parts = str.replace(/[^\d.]/g, "").split(".");
const integerPart = parts[0];
const decimalPart = parts.length > 1 ? parts[1] : "";
const withCommas = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return decimalPart ? `${withCommas}.${decimalPart}` : withCommas;
};
export const parseFormattedNumber = (value: any): number | undefined => {
if (value === null || value === undefined) return undefined;
const cleaned = persianToEnglish(String(value)).replace(/,/g, "").trim();
if (cleaned === "") return undefined;
const num = Number(cleaned);
return isNaN(num) ? undefined : num;
};

View File

@ -83,4 +83,17 @@ export const QUERY_KEYS = {
GET_ORDERS: "get_orders", GET_ORDERS: "get_orders",
GET_ORDER: "get_order", GET_ORDER: "get_order",
UPDATE_ORDER_STATUS: "update_order_status", UPDATE_ORDER_STATUS: "update_order_status",
// User Admin
GET_USERS: "get_users",
GET_USER: "get_user",
SEARCH_USERS: "search_users",
CREATE_USER: "create_user",
UPDATE_USER: "update_user",
UPDATE_USER_PROFILE: "update_user_profile",
UPDATE_USER_AVATAR: "update_user_avatar",
DELETE_USER: "delete_user",
VERIFY_USER: "verify_user",
UNVERIFY_USER: "unverify_user",
USER_STATS: "user_stats",
}; };