feat(users-admin): implement user management features including list, detail, and form pages
This commit is contained in:
parent
014b3d3f48
commit
9544517fc9
|
|
@ -22,6 +22,9 @@ describe("Smoke Tests", () => {
|
|||
cy.visit("/orders");
|
||||
cy.url().should("include", "/orders");
|
||||
|
||||
cy.visit("/users-admin");
|
||||
cy.url().should("include", "/users-admin");
|
||||
|
||||
cy.visit("/admin-users");
|
||||
cy.url().should("include", "/admin-users");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
11
src/App.tsx
11
src/App.tsx
|
|
@ -50,6 +50,11 @@ const DiscountCodeFormPage = lazy(() => import('./pages/discount-codes/discount-
|
|||
const OrdersListPage = lazy(() => import('./pages/orders/orders-list/OrdersListPage'));
|
||||
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
|
||||
const ProductsListPage = lazy(() => import('./pages/products/products-list/ProductsListPage'));
|
||||
const ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage'));
|
||||
|
|
@ -124,6 +129,12 @@ const AppRoutes = () => {
|
|||
<Route path="orders" element={<OrdersListPage />} />
|
||||
<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 */}
|
||||
<Route path="landing-hero" element={<HeroSliderPage />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Sliders,
|
||||
BadgePercent,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
|
@ -39,6 +40,16 @@ const menuItems: MenuItem[] = [
|
|||
icon: ShoppingCart,
|
||||
path: '/orders',
|
||||
},
|
||||
{
|
||||
title: 'مدیریت کاربران',
|
||||
icon: Users,
|
||||
path: '/users-admin',
|
||||
},
|
||||
{
|
||||
title: 'کدهای تخفیف',
|
||||
icon: BadgePercent,
|
||||
path: '/discount-codes',
|
||||
},
|
||||
{
|
||||
title: 'مدیریت محصولات',
|
||||
icon: Package,
|
||||
|
|
@ -58,11 +69,6 @@ const menuItems: MenuItem[] = [
|
|||
icon: Sliders,
|
||||
path: '/product-options',
|
||||
},
|
||||
{
|
||||
title: 'کدهای تخفیف',
|
||||
icon: BadgePercent,
|
||||
path: '/discount-codes',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Label } from './Typography';
|
||||
import { persianToEnglish } from '../../utils/numberUtils';
|
||||
import { persianToEnglish, formatWithThousands } from '../../utils/numberUtils';
|
||||
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label?: string;
|
||||
|
|
@ -10,10 +10,11 @@ interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, '
|
|||
inputSize?: 'sm' | 'md' | 'lg';
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
numeric?: boolean;
|
||||
thousandSeparator?: boolean;
|
||||
}
|
||||
|
||||
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 = {
|
||||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-3 py-3 text-base',
|
||||
|
|
@ -32,9 +33,24 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if ((type === 'number' || numeric) && e.target.value) {
|
||||
const convertedValue = persianToEnglish(e.target.value);
|
||||
e.target.value = convertedValue;
|
||||
let value = e.target.value;
|
||||
if ((type === 'number' || numeric) && value) {
|
||||
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);
|
||||
};
|
||||
|
|
@ -49,7 +65,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
const inputProps = {
|
||||
ref,
|
||||
id,
|
||||
type: numeric ? 'text' : type,
|
||||
type: numeric || thousandSeparator ? 'text' : type,
|
||||
inputMode: getInputMode(),
|
||||
className: inputClasses,
|
||||
onChange: handleChange,
|
||||
|
|
|
|||
|
|
@ -92,4 +92,16 @@ export const API_ROUTES = {
|
|||
GET_ORDERS: "checkout/orders",
|
||||
GET_ORDER: (id: string) => `checkout/orders/${id}`,
|
||||
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`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { parseFormattedNumber } from '@/utils/numberUtils';
|
||||
import { useDiscountCode, useCreateDiscountCode, useUpdateDiscountCode } from '../core/_hooks';
|
||||
import { CreateDiscountCodeRequest } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
|
@ -19,8 +20,16 @@ const schema = yup.object({
|
|||
value: yup.number().typeError('مقدار نامعتبر است').required('مقدار الزامی است').min(0.01, 'مقدار باید بیشتر از صفر باشد'),
|
||||
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('سطح اعمال الزامی است'),
|
||||
min_purchase_amount: yup.number().transform((v, o) => o === '' ? undefined : v).min(0.01, 'مبلغ باید بیشتر از صفر باشد').nullable(),
|
||||
max_discount_amount: yup.number().transform((v, o) => o === '' ? undefined : v).min(0.01, 'مبلغ باید بیشتر از صفر باشد').nullable(),
|
||||
min_purchase_amount: yup
|
||||
.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(),
|
||||
user_usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).min(1, 'حداقل ۱ بار استفاده').nullable(),
|
||||
single_use: yup.boolean().required('این فیلد الزامی است'),
|
||||
|
|
@ -227,6 +236,8 @@ const DiscountCodeFormPage = () => {
|
|||
step="0.01"
|
||||
placeholder="100000"
|
||||
error={errors.min_purchase_amount?.message as string}
|
||||
thousandSeparator
|
||||
numeric
|
||||
{...register('min_purchase_amount')}
|
||||
/>
|
||||
<Input
|
||||
|
|
@ -235,6 +246,8 @@ const DiscountCodeFormPage = () => {
|
|||
step="0.01"
|
||||
placeholder="50000"
|
||||
error={errors.max_discount_amount?.message as string}
|
||||
thousandSeparator
|
||||
numeric
|
||||
{...register('max_discount_amount')}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,43 +1,13 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks';
|
||||
import { DiscountCode } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
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';
|
||||
|
||||
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 navigate = useNavigate();
|
||||
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) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
|
|
@ -101,7 +126,7 @@ const DiscountCodesListPage = () => {
|
|||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ListSkeleton />
|
||||
<Table columns={columns} data={discountCodes as any[] || []} loading={true} />
|
||||
) : !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="text-center py-12">
|
||||
|
|
@ -115,69 +140,7 @@ const DiscountCodesListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
<Table columns={columns} data={discountCodes as any[]} />
|
||||
)}
|
||||
|
||||
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف کد تخفیف">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks';
|
||||
import { Order, OrderFilters, OrderStatus } from '../core/_models';
|
||||
|
|
@ -7,6 +7,8 @@ import { Input } from "@/components/ui/Input";
|
|||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
||||
import { Table } from "@/components/ui/Table";
|
||||
import { TableColumn } from "@/types";
|
||||
import {
|
||||
ShoppingCart,
|
||||
Package,
|
||||
|
|
@ -53,23 +55,7 @@ const formatDate = (dateString: string) => {
|
|||
};
|
||||
|
||||
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="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>
|
||||
<Table columns={[]} data={[]} loading={true} />
|
||||
);
|
||||
|
||||
const OrdersListPage = () => {
|
||||
|
|
@ -87,6 +73,45 @@ const OrdersListPage = () => {
|
|||
const { data: stats, isLoading: statsLoading } = useOrderStats();
|
||||
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 = () => {
|
||||
if (statusUpdateId) {
|
||||
updateStatus(
|
||||
|
|
@ -246,68 +271,7 @@ const OrdersListPage = () => {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<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>
|
||||
|
||||
{/* صفحهبندی */}
|
||||
<Table columns={columns} data={ordersData.orders as any[]} />
|
||||
<Pagination
|
||||
currentPage={filters.page || 1}
|
||||
totalPages={Math.ceil((ordersData.total || 0) / (filters.limit || 20))}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -83,3 +83,22 @@ export const createOptionalNumberTransform = () => {
|
|||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -83,4 +83,17 @@ export const QUERY_KEYS = {
|
|||
GET_ORDERS: "get_orders",
|
||||
GET_ORDER: "get_order",
|
||||
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",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue